use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::process::Child;
use std::sync::mpsc::{self, Sender};
use std::thread;
use anyhow::{Context, Result, bail};
use crate::base_fix::{BaseFix, BaseFixAsk};
use crate::child_run::{self, Ended, Kind};
use crate::failed_run::{self, FailedRun};
use crate::github::{self, PullRequest, Ticket};
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::plugin::Plugin;
use crate::progress;
use crate::prompt;
use crate::run::{self, Goal, Reached};
use crate::session::{Logs, Sessions};
use crate::worktree::Worktree;
const SPEC_REVIEW: &str = "spec-review";
const CHECKLIST_START: &str = "<!-- thirdshift:tickets -->";
const CHECKLIST_END: &str = "<!-- /thirdshift:tickets -->";
pub fn all_closed(open: impl IntoIterator<Item = bool>) -> bool {
let mut open = open.into_iter().peekable();
open.peek().is_some() && open.all(|is_open| !is_open)
}
#[allow(clippy::too_many_arguments)]
pub fn run(
spec: &IssueUrl,
tickets: Vec<Ticket>,
worktree: Worktree,
base: &str,
goal: Goal,
base_fix: &mut BaseFix,
logs: &Logs,
parallel: NonZeroUsize,
) -> Result<Reached, FailedRun> {
let mut spec_pr = draft_spec_pr(spec, worktree.branch())?;
let (ticket_lines, landed) = land_tickets(
spec,
tickets,
&worktree,
base,
parallel,
&base_fix.ask_of_tickets(),
&mut spec_pr,
);
let ended = match landed {
Ok(checklist) => open_and_review(
spec, worktree, base, spec_pr, &checklist, goal, base_fix, logs,
),
Err(error) => Err(FailedRun {
pr_url: spec_pr.map(|pr| pr.url),
..FailedRun::from(error)
}),
};
match ended {
Ok(reached) => Ok(Reached {
ticket_lines,
..reached
}),
Err(failed) => Err(FailedRun {
ticket_lines,
..failed
}),
}
}
#[allow(clippy::too_many_arguments)]
fn open_and_review(
spec: &IssueUrl,
worktree: Worktree,
base: &str,
spec_pr: Option<PullRequest>,
checklist: &str,
goal: Goal,
base_fix: &mut BaseFix,
logs: &Logs,
) -> Result<Reached, FailedRun> {
let spec_pr = match spec_pr {
Some(pr) => pr,
None => open_spec_pr(spec, worktree.branch(), base, checklist)?,
};
let mut log = logs.path(SPEC_REVIEW);
let delivered = review_and_deliver(
spec, &worktree, base, &spec_pr, checklist, goal, base_fix, logs, &mut log,
);
match delivered {
Ok(()) => Ok(Reached {
pr_url: spec_pr.url,
goal,
log: Some(log),
ticket_lines: Vec::new(),
}),
Err(error) => {
write_checklist_or_warn(spec, &spec_pr, checklist);
Err(failed_run::fail(spec, worktree, base, &log, error))
}
}
}
enum TicketOutcome {
Running,
Landed {
pr_url: Option<String>,
base_fix: Option<String>,
},
Failed { cause: String, log: Option<String> },
Interrupted,
}
fn land_tickets(
spec: &IssueUrl,
mut tickets: Vec<Ticket>,
worktree: &Worktree,
base: &str,
parallel: NonZeroUsize,
base_fix: &BaseFixAsk,
spec_pr: &mut Option<PullRequest>,
) -> (Vec<String>, Result<String>) {
if let Err(error) = worktree.push() {
return (Vec::new(), Err(error));
}
let mut outcomes = BTreeMap::new();
let landing = run_ready_tickets(
spec,
&mut tickets,
&mut outcomes,
worktree.branch(),
base,
parallel,
base_fix,
spec_pr,
);
let lines = summarize(&tickets, &outcomes);
let landed = landing.and_then(|()| {
let not_done: Vec<String> = tickets
.iter()
.filter(|ticket| ticket.is_open)
.map(|ticket| format!("#{}", ticket.number))
.collect();
if not_done.is_empty() {
return Ok(checklist(&tickets, &outcomes));
}
for line in &lines {
progress::step(line);
}
bail!("Tickets not done: {}", not_done.join(", "));
});
(lines, landed)
}
#[allow(clippy::too_many_arguments)]
fn run_ready_tickets(
spec: &IssueUrl,
tickets: &mut Vec<Ticket>,
outcomes: &mut BTreeMap<u64, TicketOutcome>,
spec_branch: &str,
base: &str,
parallel: NonZeroUsize,
base_fix: &BaseFixAsk,
spec_pr: &mut Option<PullRequest>,
) -> Result<()> {
let (ended, endings) = mpsc::channel();
let mut running = HashSet::new();
let mut error = None;
loop {
let mut started = false;
while error.is_none() && !interrupt::requested() && running.len() < parallel.get() {
let Some(ticket) = next_ready(tickets, outcomes, &running) else {
break;
};
match start_ticket(spec, ticket, spec_branch, base_fix, ended.clone()) {
Ok(()) => {
running.insert(ticket);
outcomes.insert(ticket, TicketOutcome::Running);
started = true;
}
Err(start_error) => error = Some(start_error),
}
}
if started && let Some(pr) = spec_pr {
write_checklist_or_warn(spec, pr, &checklist(tickets, outcomes));
}
if running.is_empty() {
break;
}
let (ticket, result) = endings
.recv()
.expect("a running Ticket's thread holds a sender");
running.remove(&ticket);
let outcome = result.unwrap_or_else(|finish_error| {
let cause = format!("{finish_error:#}");
error.get_or_insert(finish_error);
TicketOutcome::Failed { cause, log: None }
});
let landed = matches!(outcome, TicketOutcome::Landed { .. });
outcomes.insert(ticket, outcome);
match github::tickets(spec) {
Ok(reread) => *tickets = reread,
Err(reread_error) => {
error.get_or_insert(reread_error);
}
}
let list = checklist(tickets, outcomes);
match spec_pr {
Some(pr) => write_checklist_or_warn(spec, pr, &list),
None if landed => match open_spec_pr(spec, spec_branch, base, &list) {
Ok(pr) => *spec_pr = Some(pr),
Err(open_error) => {
error.get_or_insert(open_error);
}
},
None => {}
}
}
if let Some(error) = error {
return Err(error);
}
if interrupt::requested() {
bail!("interrupted");
}
Ok(())
}
fn next_ready(
tickets: &[Ticket],
outcomes: &BTreeMap<u64, TicketOutcome>,
running: &HashSet<u64>,
) -> Option<u64> {
tickets
.iter()
.filter(|ticket| {
ticket.is_open
&& !ticket.has_sub_issues
&& ticket.open_blockers.is_empty()
&& !ticket
.blockers
.iter()
.any(|blocker| running.contains(blocker))
&& !outcomes.contains_key(&ticket.number)
&& !running.contains(&ticket.number)
&& ticket.labels.unready().is_none()
})
.map(|ticket| ticket.number)
.min()
}
fn summarize(tickets: &[Ticket], outcomes: &BTreeMap<u64, TicketOutcome>) -> Vec<String> {
let mut lines = Vec::new();
for ticket in by_number(tickets) {
let number = ticket.number;
if !ticket.is_open && !outcomes.contains_key(&number) {
continue;
}
let standing = standing(ticket, tickets, outcomes);
lines.push(match outcomes.get(&number) {
Some(TicketOutcome::Failed { log: Some(log), .. }) if ticket.is_open => {
format!("#{number} {standing} (session log: {log})")
}
_ => format!("#{number} {standing}"),
});
}
lines
}
fn by_number(tickets: &[Ticket]) -> Vec<&Ticket> {
let mut sorted: Vec<&Ticket> = tickets.iter().collect();
sorted.sort_by_key(|ticket| ticket.number);
sorted
}
fn standing(
ticket: &Ticket,
tickets: &[Ticket],
outcomes: &BTreeMap<u64, TicketOutcome>,
) -> String {
let number = ticket.number;
let landed = match outcomes.get(&number) {
Some(TicketOutcome::Landed { pr_url, base_fix }) => {
let mut landed = "landed".to_string();
if let Some(pr_url) = pr_url {
landed += &format!(" with {pr_url}");
}
if let Some(base_fix) = base_fix {
landed += &format!(", after Base fix {base_fix}");
}
Some(landed)
}
_ => None,
};
if !ticket.is_open {
return landed.unwrap_or_else(|| "done".to_string());
}
match outcomes.get(&number) {
Some(TicketOutcome::Running) => return "running".to_string(),
Some(TicketOutcome::Interrupted) => return "interrupted".to_string(),
Some(TicketOutcome::Failed { cause, .. }) => return format!("failed: {cause}"),
_ => {}
}
if let Some(landed) = landed {
format!("{landed}, but is still open")
} else if let Some(label) = ticket.labels.unready() {
format!("unready: labelled {label}")
} else if ticket.has_sub_issues {
"unready: has sub-issues".to_string()
} else if let Some(cycle) = cycle_through(number, tickets) {
let cycle: Vec<String> = cycle.iter().map(|number| format!("#{number}")).collect();
format!("in a cycle: {}", cycle.join(" blocked by "))
} else if ticket.open_blockers.is_empty() {
"not started".to_string()
} else {
let blockers: Vec<String> = ticket
.open_blockers
.iter()
.map(|blocker| {
if tickets.iter().any(|ticket| ticket.number == *blocker) {
format!("#{blocker}")
} else {
format!("#{blocker} (outside the Spec)")
}
})
.collect();
format!("blocked by {}", blockers.join(", "))
}
}
fn checklist(tickets: &[Ticket], outcomes: &BTreeMap<u64, TicketOutcome>) -> String {
let mut list = format!("{CHECKLIST_START}\n## Tickets\n\n");
for ticket in by_number(tickets) {
let tick = if ticket.is_open { ' ' } else { 'x' };
list += &format!(
"- [{tick}] #{} {}\n",
ticket.number,
standing(ticket, tickets, outcomes)
);
}
list + CHECKLIST_END + "\n"
}
fn with_checklist(body: &str, checklist: &str) -> String {
if let Some(start) = body.find(CHECKLIST_START)
&& let Some(end) = body[start..].find(CHECKLIST_END)
{
let end = start + end + CHECKLIST_END.len();
let end = if body[end..].starts_with('\n') {
end + 1
} else {
end
};
return format!("{}{checklist}{}", &body[..start], &body[end..]);
}
if body.is_empty() {
return checklist.to_string();
}
format!("{}\n\n{checklist}", body.trim_end())
}
fn cycle_through(start: u64, tickets: &[Ticket]) -> Option<Vec<u64>> {
let blockers = |number: u64| {
tickets
.iter()
.find(|ticket| ticket.number == number)
.map_or(&[][..], |ticket| &ticket.open_blockers[..])
};
let mut reached_from = HashMap::new();
let mut queue = VecDeque::from([start]);
while let Some(number) = queue.pop_front() {
for &blocker in blockers(number) {
if blocker == start {
let mut cycle = vec![start];
let mut at = number;
while at != start {
cycle.push(at);
at = reached_from[&at];
}
cycle.push(start);
cycle.reverse();
return Some(cycle);
}
if let Entry::Vacant(entry) = reached_from.entry(blocker) {
entry.insert(number);
queue.push_back(blocker);
}
}
}
None
}
fn start_ticket(
spec: &IssueUrl,
number: u64,
spec_branch: &str,
base_fix: &BaseFixAsk,
ended: Sender<(u64, Result<TicketOutcome>)>,
) -> Result<()> {
progress::step(format_args!("starting #{number}"));
let kind = Kind::Ticket {
spec_branch: spec_branch.to_string(),
};
let child = child_run::start(&spec.sibling(number), &kind, base_fix)?;
thread::spawn(move || {
let _ = ended.send((number, finish_ticket(number, child)));
});
Ok(())
}
fn finish_ticket(number: u64, child: Child) -> Result<TicketOutcome> {
Ok(match child_run::wait(number, child)? {
Ended::Reached { pr_url, base_fix } => {
progress::step(format_args!("#{number} landed"));
TicketOutcome::Landed { pr_url, base_fix }
}
Ended::Interrupted => {
progress::step(format_args!("#{number} interrupted"));
TicketOutcome::Interrupted
}
Ended::Failed { cause, log } => {
progress::step(format_args!("#{number} failed"));
TicketOutcome::Failed { cause, log }
}
})
}
fn draft_spec_pr(spec: &IssueUrl, spec_branch: &str) -> Result<Option<PullRequest>> {
let Some(pr) = github::pull_request_for(spec, spec_branch)?.filter(|pr| pr.is_open()) else {
return Ok(None);
};
if !pr.is_draft {
github::convert_to_draft(spec, spec_branch)?;
}
Ok(Some(pr))
}
fn open_spec_pr(
spec: &IssueUrl,
spec_branch: &str,
base: &str,
checklist: &str,
) -> Result<PullRequest> {
progress::step(format_args!("opening the Spec PR into {base} as a draft"));
let title = github::issue_title(spec)?;
let body = format!(
"The work on Spec #{number}, gathered from its Tickets on {spec_branch}.\n\n\
Closes #{number}\n\n\
{checklist}",
number = spec.number
);
github::create_draft_pr(spec, spec_branch, base, &title, &body)?;
github::pull_request_for(spec, spec_branch)?.context("the Spec PR just opened is not found")
}
fn write_checklist(spec: &IssueUrl, pr: &PullRequest, checklist: &str) -> Result<()> {
let body = github::pr_body(spec, pr.number)?;
let updated = with_checklist(&body, checklist);
if updated != body {
progress::step("updating the Spec PR's Tickets checklist");
github::set_pr_body(spec, pr.number, &updated)?;
}
Ok(())
}
fn write_checklist_or_warn(spec: &IssueUrl, pr: &PullRequest, checklist: &str) {
if let Err(error) = write_checklist(spec, pr, checklist) {
progress::step(format_args!(
"could not update the Spec PR's Tickets checklist: {error:#}"
));
}
}
#[allow(clippy::too_many_arguments)]
fn review_and_deliver(
spec: &IssueUrl,
worktree: &Worktree,
base: &str,
spec_pr: &PullRequest,
checklist: &str,
goal: Goal,
base_fix: &mut BaseFix,
logs: &Logs,
log: &mut PathBuf,
) -> Result<()> {
let spec_branch = worktree.branch();
worktree.fast_forward_to_origin()?;
let plugin = Plugin::write()?;
Sessions::within(logs, worktree.path(), plugin.path(), |sessions| {
let mut run_session = |kind: &str, prompt: &str| sessions.run(kind, prompt, log);
run_session(
SPEC_REVIEW,
&prompt::spec_review(spec, base, spec_branch, &spec_pr.url),
)?;
worktree.push()?;
write_checklist(spec, spec_pr, checklist)?;
let pr = run::mark_pr_ready(spec, spec_branch, base)?;
run::deliver(spec, worktree, base, &pr, goal, base_fix, &mut run_session)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::labels::{Label, Labels, READY_FOR_AGENT, UNREADY};
fn ticket(number: u64, is_open: bool, blockers: &[u64], open_blockers: &[u64]) -> Ticket {
Ticket {
number,
is_open,
labels: Labels::default(),
has_sub_issues: false,
blockers: blockers.to_vec(),
open_blockers: open_blockers.to_vec(),
}
}
#[test]
fn a_ticket_waits_for_a_blocker_whose_run_closed_its_issue_but_has_not_ended() {
let tickets = [
ticket(21, false, &[], &[]),
ticket(22, false, &[], &[]),
ticket(23, true, &[21, 22], &[]),
];
let outcomes = BTreeMap::from([(
21,
TicketOutcome::Landed {
pr_url: None,
base_fix: None,
},
)]);
assert_eq!(next_ready(&tickets, &outcomes, &HashSet::from([22])), None);
assert_eq!(next_ready(&tickets, &outcomes, &HashSet::new()), Some(23));
}
#[test]
fn each_unready_label_keeps_a_ticket_from_running_and_is_named_in_its_line() {
for label in UNREADY {
let mut unready = ticket(21, true, &[], &[]);
unready.labels = [READY_FOR_AGENT, label]
.map(Label::name)
.into_iter()
.collect();
let tickets = [unready, ticket(22, true, &[], &[])];
let outcomes = BTreeMap::new();
assert_eq!(
next_ready(&tickets, &outcomes, &HashSet::new()),
Some(22),
"{label}"
);
assert_eq!(
summarize(&tickets, &outcomes)[0],
format!("#21 unready: labelled {label}")
);
}
}
#[test]
fn a_failed_tickets_line_says_its_cause() {
let tickets = [ticket(23, true, &[], &[])];
let cause = "git fetch origin issue-21 failed: \
error: fetching ref refs/remotes/origin/issue-21 failed";
let outcomes = BTreeMap::from([(
23,
TicketOutcome::Failed {
cause: cause.to_string(),
log: None,
},
)]);
assert_eq!(
summarize(&tickets, &outcomes),
["#23 failed: git fetch origin issue-21 failed: \
error: fetching ref refs/remotes/origin/issue-21 failed"]
);
}
#[test]
fn the_checklist_ticks_done_tickets_and_says_where_each_other_stands() {
let mut unready = ticket(26, true, &[], &[]);
unready.labels = ["needs-info"].into_iter().collect();
let tickets = [
ticket(23, true, &[], &[]),
ticket(21, false, &[], &[]),
ticket(22, false, &[], &[]),
ticket(24, true, &[23], &[23]),
ticket(25, true, &[], &[]),
unready,
ticket(27, true, &[], &[]),
];
let outcomes = BTreeMap::from([
(
21,
TicketOutcome::Landed {
pr_url: Some("https://x/pull/1".to_string()),
base_fix: None,
},
),
(
23,
TicketOutcome::Failed {
cause: "claude exited 1".to_string(),
log: Some("/logs/23.jsonl".to_string()),
},
),
(25, TicketOutcome::Running),
]);
assert_eq!(
checklist(&tickets, &outcomes),
"<!-- thirdshift:tickets -->\n\
## Tickets\n\
\n\
- [x] #21 landed with https://x/pull/1\n\
- [x] #22 done\n\
- [ ] #23 failed: claude exited 1\n\
- [ ] #24 blocked by #23\n\
- [ ] #25 running\n\
- [ ] #26 unready: labelled needs-info\n\
- [ ] #27 not started\n\
<!-- /thirdshift:tickets -->\n"
);
}
const LIST: &str = "<!-- thirdshift:tickets -->\nnew\n<!-- /thirdshift:tickets -->\n";
#[test]
fn the_checklist_replaces_the_one_between_the_markers_and_leaves_the_rest() {
let body = "Intro.\n\n<!-- thirdshift:tickets -->\nold\n<!-- /thirdshift:tickets -->\n\nCloses #20\n";
assert_eq!(
with_checklist(body, LIST),
format!("Intro.\n\n{LIST}\nCloses #20\n")
);
}
#[test]
fn the_checklist_is_appended_when_the_markers_are_gone() {
assert_eq!(
with_checklist("A rewritten body.\n\nCloses #20\n", LIST),
format!("A rewritten body.\n\nCloses #20\n\n{LIST}")
);
assert_eq!(with_checklist("", LIST), LIST);
}
#[test]
fn the_checklist_is_appended_when_only_one_marker_is_left() {
let body = "Text\n<!-- /thirdshift:tickets -->\n<!-- thirdshift:tickets -->\nmore";
assert_eq!(with_checklist(body, LIST), format!("{body}\n\n{LIST}"));
}
}