mod ticket_board;
use std::num::NonZeroUsize;
use std::sync::mpsc::{self, Sender};
use std::thread;
use anyhow::{Context, Result, bail};
use crate::base_fix::BaseFixAsk;
use crate::child_run::{self, Ended, Handle, Kind};
use crate::delivery::{Delivery, Opening};
use crate::failed_run::FailedRun;
use crate::github::{self, PullRequest, Ticket};
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::progress;
use crate::prompt;
use crate::run::Reached;
use crate::worktree::Worktree;
use ticket_board::TicketBoard;
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)
}
pub fn run(
tickets: Vec<Ticket>,
worktree: Worktree,
delivery: Delivery,
parallel: NonZeroUsize,
) -> Result<Reached, FailedRun> {
let (spec, base) = (delivery.issue, delivery.base);
let mut spec_pr = draft_spec_pr(spec, worktree.branch())?;
let (ticket_lines, landed) = land_tickets(
spec,
tickets,
&worktree,
base,
parallel,
&delivery.base_fix.ask_of_tickets(),
&mut spec_pr,
);
let ended = match landed {
Ok(checklist) => review_and_deliver(worktree, spec_pr, &checklist, delivery),
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
}),
}
}
fn review_and_deliver(
worktree: Worktree,
spec_pr: Option<PullRequest>,
checklist: &str,
delivery: Delivery,
) -> Result<Reached, FailedRun> {
let (spec, base) = (delivery.issue, delivery.base);
let spec_pr = match spec_pr {
Some(pr) => pr,
None => open_spec_pr(spec, worktree.branch(), base, checklist)?,
};
let opening = Opening {
kind: SPEC_REVIEW,
prompt: prompt::spec_review(spec, base, worktree.branch(), &spec_pr.url),
catch_up_from_origin: true,
};
let delivered = delivery.deliver(worktree, opening, || {
write_checklist(spec, &spec_pr, checklist)
});
if delivered.is_err() {
write_checklist_or_warn(spec, &spec_pr, checklist);
}
delivered
}
fn land_tickets(
spec: &IssueUrl,
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 board = TicketBoard::new(tickets, parallel);
let landing = run_ready_tickets(spec, &mut board, worktree.branch(), base, base_fix, spec_pr);
let lines = board.lines();
let landed = landing.and_then(|()| {
let not_done: Vec<String> = board
.not_done()
.iter()
.map(|number| format!("#{number}"))
.collect();
if not_done.is_empty() {
return Ok(board.checklist());
}
for line in &lines {
progress::step(line);
}
bail!("Tickets not done: {}", not_done.join(", "));
});
(lines, landed)
}
fn run_ready_tickets(
spec: &IssueUrl,
board: &mut TicketBoard,
spec_branch: &str,
base: &str,
base_fix: &BaseFixAsk,
spec_pr: &mut Option<PullRequest>,
) -> Result<()> {
let (ended, endings) = mpsc::channel();
let mut error = None;
loop {
let mut started = false;
loop {
if error.is_some() || interrupt::requested() {
board.stop();
}
let Some(ticket) = board.next_to_start() else {
break;
};
match start_ticket(spec, ticket, spec_branch, base_fix, ended.clone()) {
Ok(()) => {
board.started(ticket);
started = true;
}
Err(start_error) => error = Some(start_error),
}
}
if started && let Some(pr) = spec_pr {
write_checklist_or_warn(spec, pr, &board.checklist());
}
if !board.any_running() {
break;
}
let (ticket, result) = endings
.recv()
.expect("a running Ticket's thread holds a sender");
let ending = result.unwrap_or_else(|finish_error| {
let cause = format!("{finish_error:#}");
error.get_or_insert(finish_error);
Ended::Failed { cause, log: None }
});
let landed = matches!(ending, Ended::Reached { .. });
board.ended(ticket, ending);
match github::tickets(spec) {
Ok(reread) => board.reread(reread),
Err(reread_error) => {
error.get_or_insert(reread_error);
}
}
let list = board.checklist();
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 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 start_ticket(
spec: &IssueUrl,
number: u64,
spec_branch: &str,
base_fix: &BaseFixAsk,
ended: Sender<(u64, Result<Ended>)>,
) -> 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.clone())?;
thread::spawn(move || {
let _ = ended.send((number, finish_ticket(number, child)));
});
Ok(())
}
fn finish_ticket(number: u64, child: Handle) -> Result<Ended> {
let ended = child.wait()?;
progress::step(match ended {
Ended::Reached { .. } => format!("#{number} landed"),
Ended::Interrupted => format!("#{number} interrupted"),
Ended::Failed { .. } => format!("#{number} failed"),
});
Ok(ended)
}
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:#}"
));
}
}
#[cfg(test)]
mod tests {
use super::*;
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}"));
}
}