use std::path::PathBuf;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::child::{prefixed_uuid_id, AbandonIntent};
pub use crate::durable::TaskId;
use crate::id::WaveId;
use crate::planning::TaskPlan;
use crate::pr_landing::PrLandingId;
use crate::work::project::ProjectId;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TaskDataError {
#[error("invalid Task id: {0}")]
InvalidId(String),
#[error("invalid Task: {0}")]
InvalidInvariant(String),
}
prefixed_uuid_id!(TaskPrId, "pr_", TaskDataError, TaskDataError::InvalidId);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GithubPr {
pub number: u32,
pub url: String,
pub head_sha: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CiState {
Pending,
Passing,
Failing,
}
impl CiState {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Passing => "passing",
Self::Failing => "failing",
}
}
}
const LAND_TIME_PRECONDITION_CHECK: &str = "scratch-clear";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiCheck {
pub name: String,
pub url: Option<String>,
}
impl CiCheck {
pub fn land_time_precondition(&self) -> bool {
self.name == LAND_TIME_PRECONDITION_CHECK
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiObservation {
pub head_sha: String,
pub state: CiState,
pub failing_checks: Vec<CiCheck>,
pub observed_at: OffsetDateTime,
}
impl CiObservation {
pub fn failure_set(&self) -> Vec<String> {
let mut names: Vec<String> = self
.failing_checks
.iter()
.map(|check| check.name.clone())
.collect();
names.sort();
names.dedup();
names
}
pub fn repair_legal(&self) -> bool {
if self.state != CiState::Failing {
return false;
}
self.failing_checks.is_empty()
|| self
.failing_checks
.iter()
.any(|check| !check.land_time_precondition())
}
pub fn only_land_time_preconditions(&self) -> bool {
self.state == CiState::Failing
&& !self.failing_checks.is_empty()
&& self
.failing_checks
.iter()
.all(|check| check.land_time_precondition())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CiIncident {
pub identity: String,
pub landing_id: Option<PrLandingId>,
pub task_id: Option<TaskId>,
pub pr_id: Option<TaskPrId>,
pub repo: String,
pub pr_number: u32,
pub failed_head_sha: String,
pub repaired_head_sha: Option<String>,
pub failure_set: Vec<String>,
pub provider_completed_at: Option<OffsetDateTime>,
pub poll_observed_at: Option<OffsetDateTime>,
pub webhook_received_at: Option<OffsetDateTime>,
pub claimed_landing_generation: Option<u64>,
pub responded_at: Option<OffsetDateTime>,
pub green_at: Option<OffsetDateTime>,
pub merged_at: Option<OffsetDateTime>,
pub blocked_at: Option<OffsetDateTime>,
pub blocked_reason: Option<String>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GithubObservation {
pub checked_at: OffsetDateTime,
pub result: GithubObservationResult,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum GithubObservationResult {
Fresh,
Partial { reason: String },
Degraded { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TaskPrRepairKind {
AvoidableRebaseAgent,
ManualGitRepair,
}
impl TaskPrRepairKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::AvoidableRebaseAgent => "avoidable_rebase_agent",
Self::ManualGitRepair => "manual_git_repair",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PrPhase {
Working,
Publishing,
Open,
Merged,
Abandoned,
}
impl PrPhase {
pub fn as_str(self) -> &'static str {
match self {
Self::Working => "working",
Self::Publishing => "publishing",
Self::Open => "open",
Self::Merged => "merged",
Self::Abandoned => "abandoned",
}
}
pub fn is_active(self) -> bool {
matches!(self, Self::Working | Self::Publishing | Self::Open)
}
pub fn is_settled(self) -> bool {
matches!(self, Self::Merged | Self::Abandoned)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AfterMerge {
ContinueTask,
CompleteTask,
}
impl AfterMerge {
pub fn as_str(self) -> &'static str {
match self {
Self::ContinueTask => "continue_task",
Self::CompleteTask => "complete_task",
}
}
}
impl FromStr for AfterMerge {
type Err = TaskDataError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"continue_task" => Ok(Self::ContinueTask),
"complete_task" => Ok(Self::CompleteTask),
_ => Err(TaskDataError::InvalidInvariant(format!(
"invalid after-merge disposition: {value}"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrMergeMode {
User,
Auto,
}
impl PrMergeMode {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Auto => "auto",
}
}
}
impl FromStr for PrMergeMode {
type Err = TaskDataError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"user" => Ok(Self::User),
"auto" => Ok(Self::Auto),
_ => Err(TaskDataError::InvalidInvariant(format!(
"invalid PR merge mode: {value}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrMergeRequest {
pub mode: PrMergeMode,
pub requested_at: OffsetDateTime,
pub head_sha: String,
pub after_merge: AfterMerge,
pub next_slug: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrPresentation {
pub title: String,
pub body: String,
pub head_sha: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrPublication {
pub requested_at: OffsetDateTime,
pub presentation: Option<PrPresentation>,
pub github: Option<GithubPr>,
pub merge: Option<PrMergeRequest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskPr {
pub id: TaskPrId,
pub task_id: TaskId,
pub sequence: u32,
pub slug: String,
pub branch: String,
pub base_commit: String,
pub parent_pr_id: Option<TaskPrId>,
pub publication: Option<PrPublication>,
pub merge_commit: Option<String>,
pub abandoned_at: Option<OffsetDateTime>,
pub ci_observation: Option<CiObservation>,
pub github_observation: Option<GithubObservation>,
pub linear_attachment_id: Option<String>,
pub linear_comment_id: Option<String>,
pub linear_link_error: Option<String>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
impl TaskPr {
pub fn phase(&self) -> PrPhase {
if self.abandoned_at.is_some() {
PrPhase::Abandoned
} else if self.merge_commit.is_some() {
PrPhase::Merged
} else if self
.publication
.as_ref()
.is_some_and(|publication| publication.github.is_some())
{
PrPhase::Open
} else if self.publication.is_some() {
PrPhase::Publishing
} else {
PrPhase::Working
}
}
pub fn github(&self) -> Option<&GithubPr> {
self.publication
.as_ref()
.and_then(|publication| publication.github.as_ref())
}
pub fn head_sha(&self) -> Option<&str> {
self.github().and_then(|github| github.head_sha.as_deref())
}
pub fn presentation(&self) -> Option<&PrPresentation> {
let presentation = self.publication.as_ref()?.presentation.as_ref()?;
(self.head_sha() == Some(presentation.head_sha.as_str())).then_some(presentation)
}
pub fn merge_request(&self) -> Option<&PrMergeRequest> {
let request = self.publication.as_ref()?.merge.as_ref()?;
(self.head_sha() == Some(request.head_sha.as_str())).then_some(request)
}
pub fn after_merge(&self) -> AfterMerge {
self.merge_request()
.map_or(AfterMerge::ContinueTask, |request| request.after_merge)
}
pub fn next_slug(&self) -> Option<&str> {
self.merge_request()
.and_then(|request| request.next_slug.as_deref())
}
pub fn fresh_ci(&self) -> Option<&CiObservation> {
let observation = self.ci_observation.as_ref()?;
match self.head_sha() {
Some(head) if head == observation.head_sha => Some(observation),
_ => None,
}
}
pub fn merge_checks_passed(&self) -> bool {
self.phase() == PrPhase::Open
&& self
.fresh_ci()
.is_some_and(|observation| observation.state == CiState::Passing)
}
pub fn is_active(&self) -> bool {
self.phase().is_active()
}
pub fn is_settled(&self) -> bool {
self.phase().is_settled()
}
pub fn validate(&self) -> Result<(), TaskDataError> {
self._validate(true)
}
pub(crate) fn validate_persisted(&self) -> Result<(), TaskDataError> {
self._validate(false)
}
fn _validate(&self, require_presentation: bool) -> Result<(), TaskDataError> {
if self.sequence == 0 {
return Err(TaskDataError::InvalidInvariant(
"task pull request sequence starts at 1".to_string(),
));
}
if self.slug.trim().is_empty() {
return Err(TaskDataError::InvalidInvariant(
"task PR slug cannot be empty".to_string(),
));
}
if self.branch.trim().is_empty() || self.base_commit.trim().is_empty() {
return Err(TaskDataError::InvalidInvariant(
"task PR requires a branch and base commit".to_string(),
));
}
if let Some(presentation) = self
.publication
.as_ref()
.and_then(|publication| publication.presentation.as_ref())
{
if presentation.title.trim().is_empty()
|| presentation.body.trim().is_empty()
|| presentation.head_sha.trim().is_empty()
{
return Err(TaskDataError::InvalidInvariant(
"PR presentation requires non-empty title, body, and head".to_string(),
));
}
}
if let Some(publication) = &self.publication {
if let Some(github) = &publication.github {
if github.number == 0 || github.url.trim().is_empty() {
return Err(TaskDataError::InvalidInvariant(
"GitHub PR number and URL cannot be empty".to_string(),
));
}
}
if let Some(request) = &publication.merge {
match publication.presentation.as_ref() {
Some(presentation) if presentation.head_sha != request.head_sha => {
return Err(TaskDataError::InvalidInvariant(
"merge request and PR copy must name the same head".to_string(),
));
}
None if require_presentation => {
return Err(TaskDataError::InvalidInvariant(
"merge request requires reviewer-facing PR copy".to_string(),
));
}
Some(_) | None => {}
}
if request.next_slug.as_deref().is_some_and(|slug| {
slug.split('-').any(|word| {
word.is_empty()
|| !word
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
})
}) {
return Err(TaskDataError::InvalidInvariant(
"next branch slug must be lowercase kebab-case".to_string(),
));
}
if request.after_merge == AfterMerge::CompleteTask && request.next_slug.is_some() {
return Err(TaskDataError::InvalidInvariant(
"a completing pull request cannot name a next branch".to_string(),
));
}
let github = publication.github.as_ref().ok_or_else(|| {
TaskDataError::InvalidInvariant(
"merge request requires a GitHub PR".to_string(),
)
})?;
if request.head_sha.trim().is_empty()
|| github.head_sha.as_deref() != Some(request.head_sha.as_str())
{
return Err(TaskDataError::InvalidInvariant(
"merge request must name the current GitHub PR head".to_string(),
));
}
}
}
if self.merge_commit.is_some() && self.github().is_none() {
return Err(TaskDataError::InvalidInvariant(
"merged PR requires a GitHub PR".to_string(),
));
}
if self.merge_commit.is_some() && self.abandoned_at.is_some() {
return Err(TaskDataError::InvalidInvariant(
"a PR cannot be both merged and abandoned".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PmWritebackOperation {
CompleteTask,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum PmWritebackState {
Current,
Pending {
operation: PmWritebackOperation,
error: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(tag = "freshness", rename_all = "snake_case")]
pub enum Observation {
#[default]
NotRequired,
Fresh { observed_at: OffsetDateTime },
Cached { observed_at: OffsetDateTime },
Degraded {
reason: String,
cached_as_of: OffsetDateTime,
retry_at: OffsetDateTime,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Task {
pub id: TaskId,
pub plan: TaskPlan,
pub pm_writeback: PmWritebackState,
pub wave_id: WaveId,
pub project_id: ProjectId,
pub worktree: PathBuf,
pub workspace_slug: String,
pub agent: Option<String>,
pub abandon_intent: Option<AbandonIntent>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
#[serde(skip)]
pub observation: Observation,
}
impl Task {
pub fn validate(&self) -> Result<(), TaskDataError> {
if self.workspace_slug.trim().is_empty() {
return Err(TaskDataError::InvalidInvariant(format!(
"Task {} requires a workspace slug",
self.id
)));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TaskEventKind {
WorktreeInitializing {
pr_id: TaskPrId,
sequence: u32,
branch: String,
path: String,
base_commit: String,
},
Started,
BodyHandedOff {
handoff: crate::child::ChildBodyHandoff,
},
Progress {
summary: String,
},
FlowFinished {
invocation_id: String,
flow: String,
summary: String,
},
Steer {
author: crate::durable::Author,
text: String,
},
Interrupt,
PrStarted {
pr_id: TaskPrId,
sequence: u32,
branch: String,
base_commit: String,
},
PrOpened {
pr_id: TaskPrId,
sequence: u32,
number: u32,
url: String,
},
PrMerged {
pr_id: TaskPrId,
sequence: u32,
number: u32,
url: String,
merge_commit: String,
},
Completed {
summary: String,
},
Failed {
error: String,
resumable: bool,
},
}
impl TaskEventKind {
pub fn is_wave_observable(&self) -> bool {
!matches!(
self,
Self::WorktreeInitializing { .. }
| Self::Started
| Self::Progress { .. }
| Self::FlowFinished { .. }
| Self::Steer { .. }
| Self::Interrupt
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskEvent {
pub id: i64,
pub task_id: TaskId,
pub kind: TaskEventKind,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskObservation {
pub task_id: TaskId,
pub issue_identifier: String,
pub event_id: i64,
pub event: TaskEventKind,
}
impl TaskObservation {
pub fn inbox_id(&self) -> String {
format!("task-{}-{}", self.task_id, self.event_id)
}
pub fn prompt(&self) -> String {
let payload = serde_json::to_string(&self.event)
.expect("Task observation always serializes to structured JSON");
format!(
"<task_observation task_id=\"{}\" issue=\"{}\" event_id=\"{}\">\n{}\n</task_observation>",
self.task_id, self.issue_identifier, self.event_id, payload
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskLinearObservation {
pub task_id: TaskId,
pub last_revision: String,
pub last_title: String,
pub last_description: String,
pub last_success_at: OffsetDateTime,
pub degraded_reason: Option<String>,
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone)]
pub struct LinearObservationApply {
pub task_id: TaskId,
pub revision: String,
pub title: String,
pub description: String,
pub observed_at: OffsetDateTime,
pub content_steer: Option<String>,
pub follow_ups: Vec<LinearFollowUp>,
}
#[derive(Debug, Clone)]
pub struct LinearFollowUp {
pub comment_id: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinearObservationOutcome {
pub baselined: bool,
pub content_steer_applied: bool,
pub follow_ups_created: Vec<i64>,
}
#[cfg(test)]
mod tests {
use super::{
AfterMerge, GithubPr, PmWritebackOperation, PmWritebackState, PrPhase, PrPublication, Task,
TaskId, TaskObservation, TaskPr, TaskPrId,
};
use crate::planning::{LinearIssueId, TaskPlan};
fn task() -> Task {
let now = time::OffsetDateTime::now_utc();
Task {
id: TaskId::new(),
plan: TaskPlan {
id: LinearIssueId::new("issue-1").unwrap(),
identifier: "INF-123".to_string(),
title: "Ship it".to_string(),
description: String::new(),
pm_snapshot_synced_at: 1,
},
pm_writeback: PmWritebackState::Current,
wave_id: crate::id::WaveId::new(),
project_id: crate::work::project::ProjectId::new(),
worktree: "/tmp/task".into(),
workspace_slug: "ship-it".to_string(),
agent: None,
abandon_intent: None,
created_at: now,
updated_at: now,
observation: crate::work::task::Observation::NotRequired,
}
}
#[test]
fn task_ids_are_prefixed_and_round_trip() {
let task = TaskId::new();
assert_eq!(TaskId::parse(task.as_str()).unwrap(), task);
let pr = TaskPrId::new();
assert_eq!(TaskPrId::parse(pr.as_str()).unwrap(), pr);
}
#[test]
fn task_observation_has_a_stable_structured_inbox_identity() {
let observation = TaskObservation {
task_id: TaskId::from_raw("ts_example"),
issue_identifier: "INF-123".to_string(),
event_id: 42,
event: super::TaskEventKind::Failed {
error: "provider stopped".to_string(),
resumable: true,
},
};
assert_eq!(observation.inbox_id(), "task-ts_example-42");
assert!(observation.prompt().contains("<task_observation"));
assert!(observation.prompt().contains("\"kind\":\"failed\""));
}
#[test]
fn pending_pm_writeback_has_a_stable_json_shape() {
let state = PmWritebackState::Pending {
operation: PmWritebackOperation::CompleteTask,
error: "offline".to_string(),
};
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::json!({
"state": "pending",
"operation": "complete_task",
"error": "offline"
})
);
}
#[test]
fn pr_phase_is_derived_from_durable_evidence() {
let now = time::OffsetDateTime::now_utc();
let mut pr = TaskPr {
id: TaskPrId::new(),
task_id: TaskId::new(),
sequence: 1,
slug: "ship-it".to_string(),
branch: "jack/ship-it".to_string(),
base_commit: "abc".to_string(),
parent_pr_id: None,
publication: None,
merge_commit: None,
abandoned_at: None,
created_at: now,
updated_at: now,
ci_observation: None,
github_observation: None,
linear_attachment_id: None,
linear_comment_id: None,
linear_link_error: None,
};
assert_eq!(pr.phase(), PrPhase::Working);
pr.publication = Some(PrPublication {
requested_at: now,
presentation: None,
github: None,
merge: None,
});
assert_eq!(pr.phase(), PrPhase::Publishing);
pr.publication.as_mut().unwrap().github = Some(GithubPr {
number: 872,
url: "https://github.com/loopflowstudio/loopflow/pull/872".to_string(),
head_sha: None,
});
assert_eq!(pr.phase(), PrPhase::Open);
pr.merge_commit = Some("def".to_string());
assert_eq!(pr.phase(), PrPhase::Merged);
assert!(pr.validate().is_ok());
pr.abandoned_at = Some(now);
assert_eq!(pr.phase(), PrPhase::Abandoned);
assert!(pr.validate().is_err());
}
#[test]
fn merge_request_contains_its_disposition() {
let now = time::OffsetDateTime::now_utc();
let mut pr = TaskPr {
id: TaskPrId::new(),
task_id: TaskId::new(),
sequence: 1,
slug: "ship-it".to_string(),
branch: "jack/ship-it".to_string(),
base_commit: "abc".to_string(),
parent_pr_id: None,
publication: Some(PrPublication {
requested_at: now,
presentation: Some(super::PrPresentation {
title: "Ship it".to_string(),
body: "Reviewer context".to_string(),
head_sha: "head".to_string(),
}),
github: Some(GithubPr {
number: 872,
url: "https://github.com/loopflowstudio/loopflow/pull/872".to_string(),
head_sha: Some("head".to_string()),
}),
merge: Some(super::PrMergeRequest {
mode: super::PrMergeMode::User,
requested_at: now,
head_sha: "head".to_string(),
after_merge: AfterMerge::ContinueTask,
next_slug: Some("released_upgrade".to_string()),
}),
}),
merge_commit: None,
abandoned_at: None,
created_at: now,
updated_at: now,
ci_observation: None,
github_observation: None,
linear_attachment_id: None,
linear_comment_id: None,
linear_link_error: None,
};
assert!(pr.validate().is_err());
let merge = pr.publication.as_mut().unwrap().merge.as_mut().unwrap();
merge.next_slug = Some("released-upgrade".to_string());
assert!(pr.validate().is_ok());
assert!(pr.presentation().is_some());
let mut persisted = pr.clone();
persisted.publication.as_mut().unwrap().presentation = None;
assert!(persisted.validate().is_err());
assert!(persisted.validate_persisted().is_ok());
pr.publication
.as_mut()
.unwrap()
.github
.as_mut()
.unwrap()
.head_sha = Some("new-head".to_string());
assert!(pr.presentation().is_none());
assert!(pr.merge_request().is_none());
pr.publication
.as_mut()
.unwrap()
.github
.as_mut()
.unwrap()
.head_sha = Some("head".to_string());
pr.publication
.as_mut()
.unwrap()
.merge
.as_mut()
.unwrap()
.after_merge = AfterMerge::CompleteTask;
assert!(pr.validate().is_err());
}
fn open_pr(head_sha: &str, observation: Option<super::CiObservation>) -> TaskPr {
let now = time::OffsetDateTime::now_utc();
TaskPr {
id: TaskPrId::new(),
task_id: TaskId::new(),
sequence: 1,
slug: "ship-it".to_string(),
branch: "jack/ship-it".to_string(),
base_commit: "abc".to_string(),
parent_pr_id: None,
publication: Some(PrPublication {
requested_at: now,
presentation: None,
github: Some(GithubPr {
number: 900,
url: "https://github.com/loopflow/loopflow/pull/900".to_string(),
head_sha: Some(head_sha.to_string()),
}),
merge: None,
}),
merge_commit: None,
abandoned_at: None,
ci_observation: observation,
github_observation: None,
linear_attachment_id: None,
linear_comment_id: None,
linear_link_error: None,
created_at: now,
updated_at: now,
}
}
#[test]
fn fresh_ci_ignores_a_reading_for_a_past_head() {
let now = time::OffsetDateTime::now_utc();
let observation = super::CiObservation {
head_sha: "old-head".to_string(),
state: super::CiState::Failing,
failing_checks: vec![super::CiCheck {
name: "build".to_string(),
url: None,
}],
observed_at: now,
};
let current = open_pr("old-head", Some(observation.clone()));
assert_eq!(
current.fresh_ci().map(|ci| ci.state),
Some(super::CiState::Failing)
);
let moved = open_pr("new-head", Some(observation));
assert!(moved.fresh_ci().is_none());
}
#[test]
fn merge_checks_require_current_head_passing_checks() {
let observation = |head: &str, state| super::CiObservation {
head_sha: head.to_string(),
state,
failing_checks: Vec::new(),
observed_at: time::OffsetDateTime::now_utc(),
};
assert!(open_pr(
"current",
Some(observation("current", super::CiState::Passing))
)
.merge_checks_passed());
assert!(!open_pr(
"current",
Some(observation("current", super::CiState::Pending))
)
.merge_checks_passed());
assert!(
!open_pr("current", Some(observation("old", super::CiState::Passing)))
.merge_checks_passed()
);
assert!(!open_pr("current", None).merge_checks_passed());
}
fn failing(head: &str, checks: &[&str]) -> super::CiObservation {
super::CiObservation {
head_sha: head.to_string(),
state: super::CiState::Failing,
failing_checks: checks
.iter()
.map(|name| super::CiCheck {
name: name.to_string(),
url: None,
})
.collect(),
observed_at: time::OffsetDateTime::now_utc(),
}
}
#[test]
fn ci_repair_legality_follows_only_the_current_reading() {
let obs = failing("h1", &["build", "lint"]);
assert!(obs.repair_legal());
assert_eq!(
obs.failure_set(),
vec!["build".to_string(), "lint".to_string()]
);
assert_eq!(
failing("h1", &["lint", "build"]).failure_set(),
obs.failure_set()
);
assert_eq!(
failing("h1", &["build", "build"]).failure_set(),
vec!["build".to_string()]
);
let mut green = obs.clone();
green.state = super::CiState::Passing;
assert!(!green.repair_legal());
let mut pending = obs.clone();
pending.state = super::CiState::Pending;
assert!(!pending.repair_legal());
}
#[test]
fn a_head_red_only_on_a_land_time_precondition_is_not_repairable() {
assert!(!failing("h1", &["scratch-clear"]).repair_legal());
assert!(failing("h1", &["scratch-clear", "rust-test"]).repair_legal());
assert!(failing("h1", &["rust-test"]).repair_legal());
assert!(failing("h1", &[]).repair_legal());
let obs = failing("h1", &["scratch-clear"]);
assert_eq!(obs.state, super::CiState::Failing);
assert_eq!(obs.failure_set(), vec!["scratch-clear".to_string()]);
}
#[test]
fn only_land_time_preconditions_is_the_dual_of_repair_legal() {
let scratch = failing("h1", &["scratch-clear"]);
assert!(scratch.only_land_time_preconditions());
assert!(!scratch.repair_legal());
for obs in [
failing("h1", &["scratch-clear", "rust-test"]),
failing("h1", &["rust-test"]),
] {
assert!(!obs.only_land_time_preconditions());
assert!(obs.repair_legal());
}
let empty = failing("h1", &[]);
assert!(!empty.only_land_time_preconditions());
assert!(empty.repair_legal());
let mut green = scratch.clone();
green.state = super::CiState::Passing;
assert!(!green.only_land_time_preconditions());
let mut pending = scratch.clone();
pending.state = super::CiState::Pending;
assert!(!pending.only_land_time_preconditions());
}
#[test]
fn land_time_precondition_names_a_real_ci_job() {
let workflow =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.github/workflows/ci.yml");
let yaml = std::fs::read_to_string(&workflow)
.unwrap_or_else(|e| panic!("read {}: {e}", workflow.display()));
let job = format!("\n {}:", super::LAND_TIME_PRECONDITION_CHECK);
assert!(
yaml.contains(&job),
"no job named `{}` in {} — the landing preflight classification drifted",
super::LAND_TIME_PRECONDITION_CHECK,
workflow.display()
);
}
#[test]
fn pending_pm_writeback_is_valid_without_a_controller() {
let mut task = task();
task.pm_writeback = PmWritebackState::Pending {
operation: PmWritebackOperation::CompleteTask,
error: "too early".to_string(),
};
assert!(task.validate().is_ok());
}
}