use serde::{Deserialize, Serialize};
use crate::constants::{
BUG_REPRODUCER_MAX_INFO_REQUESTS, IMPLEMENTER_REVIEWER_MAX_LOOP, IMPLEMENTER_VERIFIER_MAX_LOOP,
};
use crate::markers::MarkerType;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueType {
Bug,
Feature,
Refactoring,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PipelineState {
Classifying,
Reproducing,
AwaitingInfo { attempts: u32 },
AnalyzingVision,
AwaitingInteractiveSession,
Approved,
Decomposing,
WritingTests { sub_issue: u64 },
Implementing { sub_issue: u64, attempt: u32 },
Reviewing { sub_issue: u64, attempt: u32 },
VerifyingE2e,
Stuck { reason: String },
Merged,
Rejected { reason: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineTracker {
pub issue_id: u64,
pub issue_type: Option<IssueType>,
pub state: PipelineState,
pub sub_issues: Vec<u64>,
pub current_sub_issue_index: usize,
pub branch_name: Option<String>,
}
impl PipelineTracker {
pub fn new(issue_id: u64) -> Self {
Self {
issue_id,
issue_type: None,
state: PipelineState::Classifying,
sub_issues: Vec::new(),
current_sub_issue_index: 0,
branch_name: None,
}
}
pub fn on_marker(
&mut self,
marker: &MarkerType,
attrs: &std::collections::HashMap<String, String>,
) -> PipelineAction {
match (&self.state, marker) {
(PipelineState::Reproducing, MarkerType::Reproduced) => {
let reproduced = attrs
.get("reproduced")
.map(|v| v == "true")
.unwrap_or(false);
if reproduced {
self.state = PipelineState::Approved;
PipelineAction::PostMarker(MarkerType::Approved)
} else {
let attempts = match &self.state {
PipelineState::AwaitingInfo { attempts } => *attempts,
_ => 0,
};
let new_attempts = attempts + 1;
if new_attempts >= BUG_REPRODUCER_MAX_INFO_REQUESTS {
self.state = PipelineState::Rejected {
reason: "Bug could not be reproduced after maximum attempts".into(),
};
PipelineAction::CloseIssue("Could not reproduce after 3 attempts.".into())
} else {
self.state = PipelineState::AwaitingInfo {
attempts: new_attempts,
};
PipelineAction::RequestInfo
}
}
}
(PipelineState::Approved, MarkerType::Approved)
| (PipelineState::AwaitingInteractiveSession, MarkerType::Approved) => {
self.state = PipelineState::Decomposing;
PipelineAction::Decompose
}
(PipelineState::Decomposing, _) => self.advance_to_next_sub_issue(),
(PipelineState::Reviewing { .. }, MarkerType::Reviewed) => {
self.current_sub_issue_index += 1;
self.advance_to_next_sub_issue()
}
(PipelineState::VerifyingE2e, MarkerType::Verified) => {
self.state = PipelineState::Merged;
PipelineAction::MergeToDev
}
(_, MarkerType::Stuck) => {
self.state = PipelineState::Stuck {
reason: "Loop limit exceeded".into(),
};
PipelineAction::WaitForHuman
}
_ => PipelineAction::None,
}
}
pub fn on_implementer_done(&mut self, tests_passed: bool) -> PipelineAction {
match &self.state {
PipelineState::Implementing { sub_issue, attempt } => {
let sub = *sub_issue;
let att = *attempt;
if tests_passed {
self.state = PipelineState::Reviewing {
sub_issue: sub,
attempt: 1,
};
PipelineAction::DispatchReviewer(sub)
} else if att >= IMPLEMENTER_VERIFIER_MAX_LOOP {
self.state = PipelineState::Stuck {
reason: format!("Implementer failed to pass tests after {} attempts", att),
};
PipelineAction::PostStuck(format!(
"Implementer-Verifier loop exceeded {} iterations for sub-issue #{}",
IMPLEMENTER_VERIFIER_MAX_LOOP, sub
))
} else {
self.state = PipelineState::Implementing {
sub_issue: sub,
attempt: att + 1,
};
PipelineAction::DispatchImplementer(sub)
}
}
_ => PipelineAction::None,
}
}
pub fn on_review_done(&mut self, passed: bool) -> PipelineAction {
match &self.state {
PipelineState::Reviewing { sub_issue, attempt } => {
let sub = *sub_issue;
let att = *attempt;
if passed {
self.current_sub_issue_index += 1;
self.advance_to_next_sub_issue()
} else if att >= IMPLEMENTER_REVIEWER_MAX_LOOP {
self.state = PipelineState::Stuck {
reason: format!(
"Review loop exceeded {} rounds for sub-issue #{}",
att, sub
),
};
PipelineAction::PostStuck(format!(
"Reviewer-Implementer loop exceeded {} iterations for sub-issue #{}",
IMPLEMENTER_REVIEWER_MAX_LOOP, sub
))
} else {
self.state = PipelineState::Implementing {
sub_issue: sub,
attempt: att + 1,
};
PipelineAction::DispatchImplementer(sub)
}
}
_ => PipelineAction::None,
}
}
pub fn classify(&mut self, issue_type: IssueType) -> PipelineAction {
self.issue_type = Some(issue_type.clone());
match issue_type {
IssueType::Bug => {
self.state = PipelineState::Reproducing;
PipelineAction::DispatchBugReproducer
}
IssueType::Feature | IssueType::Refactoring => {
self.state = PipelineState::AnalyzingVision;
PipelineAction::DispatchVisionGapAnalyst
}
}
}
pub fn on_vision_analysis_done(&mut self) -> PipelineAction {
self.state = PipelineState::AwaitingInteractiveSession;
PipelineAction::AwaitInteractiveSession
}
pub fn set_sub_issues(&mut self, sub_issues: Vec<u64>) {
self.sub_issues = sub_issues;
self.current_sub_issue_index = 0;
}
pub fn on_human_direction(&mut self) -> PipelineAction {
if let Some(&sub) = self.sub_issues.get(self.current_sub_issue_index) {
self.state = PipelineState::Implementing {
sub_issue: sub,
attempt: 1,
};
PipelineAction::DispatchImplementer(sub)
} else {
PipelineAction::None
}
}
fn advance_to_next_sub_issue(&mut self) -> PipelineAction {
if self.current_sub_issue_index >= self.sub_issues.len() {
self.state = PipelineState::VerifyingE2e;
PipelineAction::DispatchVerifierE2e
} else {
let sub = self.sub_issues[self.current_sub_issue_index];
self.state = PipelineState::WritingTests { sub_issue: sub };
PipelineAction::DispatchVerifierTests(sub)
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PipelineAction {
None,
PostMarker(MarkerType),
CloseIssue(String),
RequestInfo,
Decompose,
DispatchBugReproducer,
DispatchVisionGapAnalyst,
AwaitInteractiveSession,
DispatchVerifierTests(u64),
DispatchImplementer(u64),
DispatchReviewer(u64),
DispatchVerifierE2e,
PostStuck(String),
WaitForHuman,
MergeToDev,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn new_tracker_starts_classifying() {
let t = PipelineTracker::new(42);
assert_eq!(t.state, PipelineState::Classifying);
assert_eq!(t.issue_id, 42);
assert!(t.issue_type.is_none());
}
#[test]
fn classify_bug_dispatches_reproducer() {
let mut t = PipelineTracker::new(1);
let action = t.classify(IssueType::Bug);
assert_eq!(t.state, PipelineState::Reproducing);
assert_eq!(action, PipelineAction::DispatchBugReproducer);
}
#[test]
fn classify_feature_dispatches_vision_analyst() {
let mut t = PipelineTracker::new(1);
let action = t.classify(IssueType::Feature);
assert_eq!(t.state, PipelineState::AnalyzingVision);
assert_eq!(action, PipelineAction::DispatchVisionGapAnalyst);
}
#[test]
fn bug_reproduced_true_auto_approves() {
let mut t = PipelineTracker::new(1);
t.classify(IssueType::Bug);
let mut attrs = HashMap::new();
attrs.insert("reproduced".into(), "true".into());
let action = t.on_marker(&MarkerType::Reproduced, &attrs);
assert_eq!(t.state, PipelineState::Approved);
assert_eq!(action, PipelineAction::PostMarker(MarkerType::Approved));
}
#[test]
fn bug_not_reproduced_requests_info() {
let mut t = PipelineTracker::new(1);
t.classify(IssueType::Bug);
let mut attrs = HashMap::new();
attrs.insert("reproduced".into(), "false".into());
let action = t.on_marker(&MarkerType::Reproduced, &attrs);
assert!(matches!(
t.state,
PipelineState::AwaitingInfo { attempts: 1 }
));
assert_eq!(action, PipelineAction::RequestInfo);
}
#[test]
fn vision_done_awaits_session() {
let mut t = PipelineTracker::new(1);
t.classify(IssueType::Feature);
let action = t.on_vision_analysis_done();
assert_eq!(t.state, PipelineState::AwaitingInteractiveSession);
assert_eq!(action, PipelineAction::AwaitInteractiveSession);
}
#[test]
fn approved_triggers_decompose() {
let mut t = PipelineTracker::new(1);
t.state = PipelineState::AwaitingInteractiveSession;
let action = t.on_marker(&MarkerType::Approved, &HashMap::new());
assert_eq!(t.state, PipelineState::Decomposing);
assert_eq!(action, PipelineAction::Decompose);
}
#[test]
fn sub_issue_flow_tests_implement_review() {
let mut t = PipelineTracker::new(1);
t.state = PipelineState::Approved;
t.set_sub_issues(vec![100, 101]);
let action = t.advance_to_next_sub_issue();
assert_eq!(action, PipelineAction::DispatchVerifierTests(100));
assert!(matches!(
t.state,
PipelineState::WritingTests { sub_issue: 100 }
));
t.state = PipelineState::Implementing {
sub_issue: 100,
attempt: 1,
};
let action = t.on_implementer_done(true);
assert_eq!(action, PipelineAction::DispatchReviewer(100));
assert!(matches!(
t.state,
PipelineState::Reviewing { sub_issue: 100, .. }
));
let action = t.on_review_done(true);
assert_eq!(action, PipelineAction::DispatchVerifierTests(101));
}
#[test]
fn all_sub_issues_done_triggers_e2e() {
let mut t = PipelineTracker::new(1);
t.set_sub_issues(vec![100]);
t.current_sub_issue_index = 0;
t.state = PipelineState::Reviewing {
sub_issue: 100,
attempt: 1,
};
let action = t.on_review_done(true);
assert_eq!(t.state, PipelineState::VerifyingE2e);
assert_eq!(action, PipelineAction::DispatchVerifierE2e);
}
#[test]
fn verified_merges_to_dev() {
let mut t = PipelineTracker::new(1);
t.state = PipelineState::VerifyingE2e;
let action = t.on_marker(&MarkerType::Verified, &HashMap::new());
assert_eq!(t.state, PipelineState::Merged);
assert_eq!(action, PipelineAction::MergeToDev);
}
#[test]
fn implementer_exceeds_loop_limit_stuck() {
let mut t = PipelineTracker::new(1);
t.state = PipelineState::Implementing {
sub_issue: 100,
attempt: IMPLEMENTER_VERIFIER_MAX_LOOP,
};
let action = t.on_implementer_done(false);
assert!(matches!(t.state, PipelineState::Stuck { .. }));
assert!(matches!(action, PipelineAction::PostStuck(_)));
}
#[test]
fn reviewer_exceeds_loop_limit_stuck() {
let mut t = PipelineTracker::new(1);
t.state = PipelineState::Reviewing {
sub_issue: 100,
attempt: IMPLEMENTER_REVIEWER_MAX_LOOP,
};
let action = t.on_review_done(false);
assert!(matches!(t.state, PipelineState::Stuck { .. }));
assert!(matches!(action, PipelineAction::PostStuck(_)));
}
#[test]
fn stuck_marker_transitions() {
let mut t = PipelineTracker::new(1);
t.state = PipelineState::Implementing {
sub_issue: 100,
attempt: 5,
};
let action = t.on_marker(&MarkerType::Stuck, &HashMap::new());
assert!(matches!(t.state, PipelineState::Stuck { .. }));
assert_eq!(action, PipelineAction::WaitForHuman);
}
#[test]
fn human_direction_resets_loop() {
let mut t = PipelineTracker::new(1);
t.set_sub_issues(vec![100, 101]);
t.current_sub_issue_index = 0;
t.state = PipelineState::Stuck {
reason: "test".into(),
};
let action = t.on_human_direction();
assert!(matches!(
t.state,
PipelineState::Implementing {
sub_issue: 100,
attempt: 1
}
));
assert_eq!(action, PipelineAction::DispatchImplementer(100));
}
#[test]
fn classify_refactoring_same_as_feature() {
let mut t = PipelineTracker::new(1);
let action = t.classify(IssueType::Refactoring);
assert_eq!(t.state, PipelineState::AnalyzingVision);
assert_eq!(action, PipelineAction::DispatchVisionGapAnalyst);
assert_eq!(t.issue_type, Some(IssueType::Refactoring));
}
#[test]
fn no_sub_issues_goes_to_e2e() {
let mut t = PipelineTracker::new(1);
t.set_sub_issues(vec![]);
let action = t.advance_to_next_sub_issue();
assert_eq!(t.state, PipelineState::VerifyingE2e);
assert_eq!(action, PipelineAction::DispatchVerifierE2e);
}
#[test]
fn serialization_roundtrip() {
let mut t = PipelineTracker::new(42);
t.classify(IssueType::Bug);
t.set_sub_issues(vec![100, 101]);
t.branch_name = Some("Feature/#42".into());
let json = serde_json::to_string(&t).unwrap();
let deserialized: PipelineTracker = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.issue_id, 42);
assert_eq!(deserialized.issue_type, Some(IssueType::Bug));
assert_eq!(deserialized.sub_issues, vec![100, 101]);
}
#[test]
fn review_fail_loops_to_implementer() {
let mut t = PipelineTracker::new(1);
t.set_sub_issues(vec![100]);
t.state = PipelineState::Reviewing {
sub_issue: 100,
attempt: 3,
};
let action = t.on_review_done(false);
assert!(matches!(
t.state,
PipelineState::Implementing {
sub_issue: 100,
attempt: 4
}
));
assert_eq!(action, PipelineAction::DispatchImplementer(100));
}
}