use std::time::Duration;
use crate::domain::gh::{
CommitId, Gh, GhError, PrSnapshot, PullRequest, ReviewComment, ReviewPayload, ReviewRecord,
};
use crate::script::{FlowError, Scope, within};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ReviewRequest {
pull: PullRequest,
body: String,
event: String,
comments: Vec<ReviewComment>,
marker: String,
step_deadline: Duration,
}
impl ReviewRequest {
#[must_use]
pub fn new(pull: PullRequest, event: impl Into<String>, body: impl Into<String>) -> Self {
Self {
pull,
body: body.into(),
event: event.into(),
comments: Vec::new(),
marker: String::new(),
step_deadline: Duration::from_secs(120),
}
}
#[must_use]
pub fn with_comments(mut self, comments: Vec<ReviewComment>) -> Self {
self.comments = comments;
self
}
#[must_use]
pub fn with_marker(mut self, marker: impl Into<String>) -> Self {
self.marker = marker.into();
self
}
#[must_use]
pub fn step_deadline(mut self, deadline: Duration) -> Self {
self.step_deadline = deadline;
self
}
#[must_use]
pub const fn pull(&self) -> &PullRequest {
&self.pull
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
#[must_use]
pub fn event(&self) -> &str {
&self.event
}
#[must_use]
pub fn comments(&self) -> &[ReviewComment] {
&self.comments
}
#[must_use]
pub fn marker(&self) -> &str {
&self.marker
}
#[must_use]
pub const fn step_budget(&self) -> Duration {
self.step_deadline
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReviewOutcome {
Published {
review_id: u64,
commit_id: CommitId,
verified: bool,
},
Unknown {
commit_id: CommitId,
reason: String,
},
TargetMoved {
reviewed: CommitId,
current: String,
},
Refused {
reason: String,
},
Incomplete {
commit_id: CommitId,
reason: String,
},
Pending {
review_id: u64,
commit_id: CommitId,
},
Partial {
review_id: u64,
commit_id: CommitId,
applied: usize,
intended: usize,
},
Unverified {
review_id: u64,
commit_id: CommitId,
reason: String,
},
}
impl ReviewOutcome {
#[must_use]
pub const fn is_published(&self) -> bool {
matches!(self, Self::Published { .. })
}
#[must_use]
pub const fn review_id(&self) -> Option<u64> {
match *self {
Self::Published { review_id, .. }
| Self::Pending { review_id, .. }
| Self::Partial { review_id, .. }
| Self::Unverified { review_id, .. } => Some(review_id),
Self::Unknown { .. }
| Self::TargetMoved { .. }
| Self::Refused { .. }
| Self::Incomplete { .. } => None,
}
}
#[must_use]
pub fn commit_id(&self) -> Option<&CommitId> {
match *self {
Self::Published { ref commit_id, .. }
| Self::Unknown { ref commit_id, .. }
| Self::Incomplete { ref commit_id, .. }
| Self::Pending { ref commit_id, .. }
| Self::Partial { ref commit_id, .. }
| Self::Unverified { ref commit_id, .. } => Some(commit_id),
Self::TargetMoved { ref reviewed, .. } => Some(reviewed),
Self::Refused { .. } => None,
}
}
#[must_use]
pub const fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown { .. })
}
#[must_use]
pub const fn is_unverified(&self) -> bool {
matches!(self, Self::Unverified { .. })
}
#[must_use]
pub const fn is_pending(&self) -> bool {
matches!(self, Self::Pending { .. })
}
#[must_use]
pub const fn is_partial(&self) -> bool {
matches!(self, Self::Partial { .. })
}
#[must_use]
pub const fn is_incomplete(&self) -> bool {
matches!(self, Self::Incomplete { .. })
}
#[must_use]
pub fn unknown_reason(&self) -> Option<&str> {
match *self {
Self::Unknown { ref reason, .. } => Some(reason),
Self::Published { .. }
| Self::TargetMoved { .. }
| Self::Refused { .. }
| Self::Incomplete { .. }
| Self::Pending { .. }
| Self::Partial { .. }
| Self::Unverified { .. } => None,
}
}
}
#[cfg(feature = "process")]
pub async fn review_pr(
scope: Scope,
gh: Gh,
mut request: ReviewRequest,
script: impl Fn(&PrSnapshot, &Scope) -> ReviewScript,
) -> Result<ReviewOutcome, FlowError> {
let deadline = request.step_deadline;
let marker = if request.marker.is_empty() {
scope.key().to_string()
} else {
request.marker.clone()
};
let comments = std::mem::take(&mut request.comments);
let snapshot = match within(&scope, "snapshot", deadline, async {
match gh.snapshot(&request.pull).await {
Ok(snapshot) => Ok(SnapshotStep::Pinned(snapshot)),
Err(GhError::MovedRepository {
requested,
canonical,
}) => Ok(SnapshotStep::Moved {
requested,
canonical,
}),
Err(other) => Err(FlowError::from(other)),
}
})
.await?
{
SnapshotStep::Pinned(snapshot) => snapshot,
SnapshotStep::Moved {
requested,
canonical,
} => {
return Ok(ReviewOutcome::Refused {
reason: format!(
"the repository {requested} moved to {canonical}; the subject identity is \
not re-pointed, so nothing was reviewed or published under the canonical \
name"
),
});
}
};
let reviewed = CommitId::new(snapshot.head_sha().to_owned()).map_err(|source| {
FlowError::failed(format!(
"the pull request's head is not a commit id: {source}"
))
})?;
let diff = match within(&scope, "diff", deadline, async {
match gh.read_diff(&request.pull).await {
Ok(diff) => Ok(DiffStep::Read(diff)),
Err(error) if error.is_coverage_incomplete() => {
Ok(DiffStep::Incomplete(error.to_string()))
}
Err(other) => Err(FlowError::from(other)),
}
})
.await?
{
DiffStep::Read(diff) => diff,
DiffStep::Incomplete(reason) => {
return Ok(ReviewOutcome::Incomplete {
commit_id: reviewed,
reason,
});
}
};
drop(diff);
let step = scope.enter("script")?;
let body = script(&snapshot, &step).map_err(FlowError::from);
drop(step);
let body = body?;
let payload = ReviewPayload::new(&reviewed, &request.event, body, marker)
.map_err(|source| FlowError::failed(source.to_string()))?
.with_comments(comments);
let current = within(&scope, "freshness", deadline, async {
gh.snapshot(&request.pull).await.map_err(FlowError::from)
})
.await?;
if current.head_sha() != reviewed.as_str() {
return Ok(ReviewOutcome::TargetMoved {
reviewed,
current: current.head_sha().to_owned(),
});
}
let published = within(&scope, "publish", deadline, async {
gh.publish(&request.pull, &payload)
.await
.map_err(FlowError::from)
})
.await;
let created_id = match published {
Ok(id) => Some(id),
Err(error) => {
if !may_have_landed(&error) {
let refusal = Err(error);
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "review_pr: returning an error to the caller");
return refusal;
}
None
}
};
let verified = within(&scope, "verify", deadline, async {
match gh.read_reviews(&request.pull).await {
Ok(records) => Ok(VerifyStep::Read(records)),
Err(GhError::Unauthorized { status, reason, .. }) => {
Ok(VerifyStep::Denied { status, reason })
}
Err(other) => Ok(VerifyStep::Unreadable(other.to_string())),
}
})
.await?;
let reviews = match verified {
VerifyStep::Read(records) => records,
VerifyStep::Denied { status, reason } => {
return Ok(match created_id {
Some(review_id) => ReviewOutcome::Unverified {
review_id,
commit_id: reviewed,
reason: format!(
"the review was created but the read-back lost permission (HTTP \
{status}): {reason}; the applied review id is retained and no second \
review was created"
),
},
None => ReviewOutcome::Unknown {
commit_id: reviewed,
reason: format!(
"the publication outcome could not be established, because the read \
lost permission (HTTP {status}): {reason}; no second review was created"
),
},
});
}
VerifyStep::Unreadable(reason) => {
return Ok(ReviewOutcome::Unknown {
commit_id: reviewed,
reason: format!(
"the publication outcome could not be established: {reason}; \
no second review was created"
),
});
}
};
match reconcile(&reviews, &payload, created_id) {
Reconcile::Verified(id) => Ok(ReviewOutcome::Published {
review_id: id,
commit_id: reviewed,
verified: true,
}),
Reconcile::Pending(id) => Ok(ReviewOutcome::Pending {
review_id: id,
commit_id: reviewed,
}),
Reconcile::Partial {
id,
applied,
intended,
} => Ok(ReviewOutcome::Partial {
review_id: id,
commit_id: reviewed,
applied,
intended,
}),
Reconcile::None => match created_id {
Some(id) => Err(FlowError::failed(format!(
"GitHub accepted review {id} at {reviewed} but the read-back did not return \
it as published; no second review was created"
))),
None => Ok(ReviewOutcome::Unknown {
commit_id: reviewed,
reason: String::from(
"the publication response was lost and the read-back found no matching \
review; the review may or may not exist, so nothing was created again",
),
}),
},
}
}
#[cfg(feature = "process")]
enum SnapshotStep {
Pinned(PrSnapshot),
Moved {
requested: String,
canonical: String,
},
}
#[cfg(feature = "process")]
enum DiffStep {
Read(crate::domain::gh::PullDiff),
Incomplete(String),
}
#[cfg(feature = "process")]
enum VerifyStep {
Read(Vec<ReviewRecord>),
Denied {
status: u16,
reason: String,
},
Unreadable(String),
}
#[cfg(feature = "process")]
enum Reconcile {
Verified(u64),
Pending(u64),
Partial {
id: u64,
applied: usize,
intended: usize,
},
None,
}
#[cfg(feature = "process")]
fn reconcile(
reviews: &[ReviewRecord],
payload: &ReviewPayload,
created_id: Option<u64>,
) -> Reconcile {
if let Some(id) = created_id {
let Some(record) = reviews.iter().find(|record| record.id() == id) else {
return Reconcile::None;
};
if record.matches(payload) {
return Reconcile::Verified(id);
}
if record.is_pending() && record.matches_subject_body(payload) {
return Reconcile::Pending(id);
}
if record.matches_except_comments(payload) {
return Reconcile::Partial {
id,
applied: record.applied_comments(),
intended: payload.comments().len(),
};
}
return Reconcile::None;
}
if let Some(record) = reviews.iter().find(|record| record.matches(payload)) {
return Reconcile::Verified(record.id());
}
if let Some(record) = reviews
.iter()
.find(|record| record.is_pending() && record.matches_subject_body(payload))
{
return Reconcile::Pending(record.id());
}
if let Some(record) = reviews
.iter()
.find(|record| record.matches_except_comments(payload))
{
return Reconcile::Partial {
id: record.id(),
applied: record.applied_comments(),
intended: payload.comments().len(),
};
}
Reconcile::None
}
#[cfg(feature = "process")]
fn may_have_landed(error: &FlowError) -> bool {
match *error {
FlowError::Bot { ref source, .. } => !matches!(
**source,
crate::error::BotError::DomainError {
certainty: crate::error::DispatchCertainty::Refused,
..
}
),
FlowError::TimedOut { .. } | FlowError::Cancelled { .. } => true,
_ => false,
}
}
#[cfg(feature = "process")]
pub type ReviewScript = std::result::Result<String, ScriptFailure>;
#[cfg(feature = "process")]
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ScriptFailure {
reason: String,
}
#[cfg(feature = "process")]
impl ScriptFailure {
#[must_use]
pub fn new(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
}
}
#[must_use]
pub fn reason(&self) -> &str {
&self.reason
}
}
#[cfg(feature = "process")]
impl std::fmt::Display for ScriptFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.reason)
}
}
#[cfg(feature = "process")]
impl std::error::Error for ScriptFailure {}
#[cfg(feature = "process")]
impl From<ScriptFailure> for FlowError {
fn from(source: ScriptFailure) -> Self {
FlowError::failed(source.reason)
}
}