use std::fmt;
use std::num::NonZeroUsize;
use anyhow::Result;
use chrono::{TimeDelta, Utc};
use crate::base_fix;
use crate::branch::{self, Started};
use crate::claim;
use crate::git::Git;
use crate::github::{self, ListedIssue, Shaping};
use crate::issue::{IssueUrl, Repo};
use crate::labels::{Label, READY_FOR_AGENT};
use crate::launch::{self, AlreadyRunning, Launch, Start};
use crate::progress;
use crate::spec_run;
pub enum Outcome {
Taken(Taken),
Skipped(Skipped),
}
pub struct Taken {
pub issue: IssueUrl,
pub title: String,
pub base: String,
pub is_spec: bool,
}
pub enum Skipped {
AlreadyRunning(AlreadyRunning),
AtClaimLimit {
repo: Repo,
claimed: usize,
limit: NonZeroUsize,
},
NoReadyIssue(Repo),
}
impl fmt::Display for Skipped {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::AlreadyRunning(running) => running.fmt(f),
Self::AtClaimLimit {
repo,
claimed,
limit,
} => write!(
f,
"at the Claim limit on {}: {claimed} open issue(s) labelled {}, \
pickup.limit is {limit}",
repo.slug(),
claim::IN_PROGRESS
),
Self::NoReadyIssue(repo) => write!(f, "no Ready issue on {}", repo.slug()),
}
}
}
pub fn run(base: Option<&str>, limit: NonZeroUsize) -> Result<Outcome> {
let Launch {
git, repo, base, ..
} = match launch::start(base)? {
Start::Clear(launch) => launch,
Start::AlreadyRunning(running) => {
return Ok(Outcome::Skipped(Skipped::AlreadyRunning(running)));
}
};
claim::sweep(&repo);
let claimed = claim::open_count(&repo)?;
if claimed >= limit.get() {
return Ok(Outcome::Skipped(Skipped::AtClaimLimit {
repo,
claimed,
limit,
}));
}
let candidates = github::open_issues_labelled(&repo.slug(), READY_FOR_AGENT)?;
let Some((ready, is_spec)) = Search::new(&git, candidates).first_ready()? else {
return Ok(Outcome::Skipped(Skipped::NoReadyIssue(repo)));
};
progress::step(format_args!(
"taking Ready issue #{} \"{}\", as thirdshift {} would",
ready.issue.number, ready.title, ready.issue.url
));
Ok(Outcome::Taken(Taken {
issue: ready.issue,
title: ready.title,
base,
is_spec,
}))
}
const SETTLE: TimeDelta = TimeDelta::minutes(10);
#[derive(Clone)]
enum Standing {
Ready {
is_spec: bool,
},
PassedOver(Reason),
}
#[derive(Clone)]
enum Reason {
Unready(Label),
Claimed,
Ticket(IssueUrl),
BaseFix,
Blocked(Vec<u64>),
Started(Started),
TicketsClosed,
Unsettled(Shaping),
}
struct Search<'a> {
launch: &'a Git,
candidates: Vec<ListedIssue>,
standings: Vec<Option<Standing>>,
}
impl<'a> Search<'a> {
fn new(launch: &'a Git, mut candidates: Vec<ListedIssue>) -> Self {
candidates.sort_by_key(|candidate| candidate.issue.number);
let standings = vec![None; candidates.len()];
Search {
launch,
candidates,
standings,
}
}
fn first_ready(mut self) -> Result<Option<(ListedIssue, bool)>> {
for at in 0..self.candidates.len() {
match self.standing(at)? {
Standing::Ready { is_spec } => {
return Ok(Some((self.candidates.swap_remove(at), is_spec)));
}
Standing::PassedOver(reason) => {
if let Some(line) = self.line(at, &reason)? {
progress::step(line);
}
}
}
}
Ok(None)
}
fn standing(&mut self, at: usize) -> Result<Standing> {
if let Some(standing) = &self.standings[at] {
return Ok(standing.clone());
}
let standing = standing_of(self.launch, &self.candidates[at])?;
self.standings[at] = Some(standing.clone());
Ok(standing)
}
fn line(&mut self, at: usize, reason: &Reason) -> Result<Option<String>> {
let number = self.candidates[at].issue.number;
let why = match reason {
Reason::Unready(label) => format!("labelled {label}"),
Reason::Claimed => format!("labelled {}", claim::IN_PROGRESS),
Reason::Ticket(spec) => {
let listed = self.candidates.iter().position(|candidate| {
candidate.issue.number == spec.number && candidate.issue.in_same_repo(spec)
});
let spec_is_ready = match listed {
Some(spec) => matches!(self.standing(spec)?, Standing::Ready { .. }),
None => false,
};
if spec_is_ready {
return Ok(None);
}
let spec = if self.candidates[at].issue.in_same_repo(spec) {
format!("#{}", spec.number)
} else {
format!("{}#{}", spec.repo_slug(), spec.number)
};
format!("is a Ticket of {spec}, which is not ready")
}
Reason::BaseFix => format!("labelled {}", base_fix::BASE_FIX),
Reason::Blocked(blockers) => {
let blockers: Vec<String> = blockers
.iter()
.map(|blocker| format!("#{blocker}"))
.collect();
format!("blocked by {}", blockers.join(", "))
}
Reason::Started(Started::Branch(branch)) => {
format!("already started: {branch} is on origin")
}
Reason::Started(Started::PullRequest(url)) => format!("already started: PR {url}"),
Reason::TicketsClosed => "every Ticket is closed".to_string(),
Reason::Unsettled(shaping) => {
let shaped = match shaping {
Shaping::Labelled => format!("labelled {READY_FOR_AGENT}"),
Shaping::SubIssues => "a sub-issue added or removed".to_string(),
Shaping::Blockers => "a \"blocked by\" link added or removed".to_string(),
};
let minutes = SETTLE.num_minutes();
format!("not settled: {shaped} less than {minutes} minutes ago")
}
};
Ok(Some(format!("#{number} {why}")))
}
}
fn standing_of(launch: &Git, candidate: &ListedIssue) -> Result<Standing> {
let passed_over = |reason| Ok(Standing::PassedOver(reason));
if let Some(label) = candidate.labels.unready() {
return passed_over(Reason::Unready(label));
}
if claim::is_on(&candidate.labels) {
return passed_over(Reason::Claimed);
}
let read = github::candidate(&candidate.issue, READY_FOR_AGENT)?;
if let Some(spec) = read.parent {
return passed_over(Reason::Ticket(spec));
}
if base_fix::is_issue(&candidate.labels) {
return passed_over(Reason::BaseFix);
}
if !read.open_blockers.is_empty() {
return passed_over(Reason::Blocked(read.open_blockers));
}
if let Some(started) = branch::started(launch, &candidate.issue)? {
return passed_over(Reason::Started(started));
}
if spec_run::all_closed(read.sub_issue_is_open.iter().copied()) {
return passed_over(Reason::TicketsClosed);
}
if let Some(shaped) = read.last_shaped
&& Utc::now() - shaped.at < SETTLE
{
return passed_over(Reason::Unsettled(shaped.by));
}
Ok(Standing::Ready {
is_spec: read.has_sub_issues(),
})
}