use anyhow::Result;
use chrono::{DateTime, TimeDelta, Utc};
use crate::base_fix;
use crate::branch::{self, Started};
use crate::claim;
use crate::git::Git;
use crate::github::{Candidate, GitHub, ListedIssue, Shaping};
use crate::issue::{IssueUrl, Repo};
use crate::labels::{Label, READY_FOR_AGENT};
use crate::progress;
use crate::spec_run;
use crate::trust::{self, UntrustedIssue};
pub struct ReadyIssue {
pub listed: ListedIssue,
pub is_spec: bool,
pub claim_generation: usize,
}
pub fn first(launch: &Git, repo: &Repo, wait: TimeDelta) -> Result<Option<ReadyIssue>> {
let reads = GitHubAndOrigin {
launch,
github: GitHub::new(),
};
let candidates = reads
.github
.open_issues_labelled(&repo.slug(), READY_FOR_AGENT)?;
Search::new(&reads, candidates, Utc::now(), wait, progress::step).first_ready()
}
pub fn refresh(launch: &Git, selected: ReadyIssue, wait: TimeDelta) -> Result<Option<ReadyIssue>> {
let reads = GitHubAndOrigin {
launch,
github: GitHub::new(),
};
let issue = selected.listed.issue;
let current = reads.github.issue(&issue)?;
if !current.is_open || !current.labels.has(READY_FOR_AGENT) {
return Ok(None);
}
let listed = ListedIssue {
issue,
title: current.title.unwrap_or_default(),
labels: current.labels,
};
match standing_of(&reads, &listed, Utc::now(), wait)? {
Standing::Ready {
is_spec,
claim_generation,
} if claim_generation == selected.claim_generation => Ok(Some(ReadyIssue {
listed,
is_spec,
claim_generation,
})),
_ => Ok(None),
}
}
trait Reads {
fn candidate(&self, issue: &IssueUrl) -> Result<Candidate>;
fn started(&self, issue: &IssueUrl) -> Result<Option<Started>>;
fn issue_trust(&self, issue: &IssueUrl) -> Result<Option<UntrustedIssue>>;
}
struct GitHubAndOrigin<'a> {
launch: &'a Git,
github: GitHub,
}
impl Reads for GitHubAndOrigin<'_> {
fn issue_trust(&self, issue: &IssueUrl) -> Result<Option<UntrustedIssue>> {
trust::issue_trust(&self.github, issue)
}
fn candidate(&self, issue: &IssueUrl) -> Result<Candidate> {
self.github.candidate(issue, READY_FOR_AGENT)
}
fn started(&self, issue: &IssueUrl) -> Result<Option<Started>> {
branch::started(self.launch, &self.github, issue)
}
}
#[derive(Clone)]
enum Standing {
Ready {
is_spec: bool,
claim_generation: usize,
},
PassedOver(Reason),
}
#[derive(Clone)]
enum Reason {
Untrusted(UntrustedIssue),
Unready(Label),
Claimed,
Ticket(IssueUrl),
BaseFix,
SecurityFix,
Blocked(Vec<u64>),
Started(Started),
TicketsClosed,
Unsettled(Shaping),
}
struct Search<'a, R, P> {
reads: &'a R,
candidates: Vec<ListedIssue>,
standings: Vec<Option<Standing>>,
now: DateTime<Utc>,
wait: TimeDelta,
passed_over: P,
}
impl<'a, R: Reads, P: FnMut(String)> Search<'a, R, P> {
fn new(
reads: &'a R,
mut candidates: Vec<ListedIssue>,
now: DateTime<Utc>,
wait: TimeDelta,
passed_over: P,
) -> Self {
candidates.sort_by_key(|candidate| candidate.issue.number);
let standings = vec![None; candidates.len()];
Search {
reads,
candidates,
standings,
now,
wait,
passed_over,
}
}
fn first_ready(mut self) -> Result<Option<ReadyIssue>> {
for at in 0..self.candidates.len() {
match self.standing(at)? {
Standing::Ready {
is_spec,
claim_generation,
} => {
let listed = self.candidates.swap_remove(at);
return Ok(Some(ReadyIssue {
listed,
is_spec,
claim_generation,
}));
}
Standing::PassedOver(reason) => {
if let Some(line) = self.line(at, &reason)? {
(self.passed_over)(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.reads, &self.candidates[at], self.now, self.wait)?;
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::Untrusted(check) => check.to_string(),
Reason::Unready(label) => format!("labelled {label}"),
Reason::Claimed => format!("labelled {}", claim::IN_PROGRESS),
Reason::SecurityFix => "a Security fix: its Security run owns it".to_string(),
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) => {
let evidence = match started {
Started::Branch(branch) => format!("{branch} is on origin"),
Started::PullRequest(url) => format!("PR {url}"),
};
format!(
"started: {evidence}; no ready-for-agent hand-back after the last Claim ended"
)
}
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 = self.wait.num_minutes();
format!("not settled: {shaped} less than {minutes} minutes ago")
}
};
Ok(Some(format!("#{number} {why}")))
}
}
fn standing_of(
reads: &impl Reads,
candidate: &ListedIssue,
now: DateTime<Utc>,
wait: TimeDelta,
) -> 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);
}
if candidate.labels.has(crate::security::fixing::SECURITY_FIX) {
return passed_over(Reason::SecurityFix);
}
let read = reads.candidate(&candidate.issue)?;
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(check) = reads.issue_trust(&candidate.issue)? {
return passed_over(Reason::Untrusted(check));
}
if let Some(started) = reads.started(&candidate.issue)?
&& !read.handed_back
{
return passed_over(Reason::Started(started));
}
if read.claimed {
return passed_over(Reason::Claimed);
}
if spec_run::all_closed(read.sub_issue_is_open.iter().copied()) {
return passed_over(Reason::TicketsClosed);
}
if wait > TimeDelta::zero()
&& let Some(shaped) = read.last_shaped
&& now - shaped.at < wait
{
return passed_over(Reason::Unsettled(shaped.by));
}
Ok(Standing::Ready {
is_spec: read.has_sub_issues(),
claim_generation: read.claim_generation,
})
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::collections::HashMap;
use chrono::TimeZone;
use super::*;
use crate::github::Shaped;
#[derive(Debug, PartialEq, Eq)]
enum Seen {
CandidateRead(u64),
StartedCheck(u64),
Line(String),
}
#[derive(Default)]
struct Facts {
parent: Option<IssueUrl>,
sub_issue_is_open: Vec<bool>,
open_blockers: Vec<u64>,
last_shaped: Option<Shaped>,
handed_back: bool,
started: Option<Started>,
}
struct InMemory<'a> {
facts: HashMap<u64, Facts>,
seen: &'a RefCell<Vec<Seen>>,
}
impl InMemory<'_> {
fn facts(&self, issue: &IssueUrl) -> Option<&Facts> {
self.facts.get(&issue.number)
}
}
impl Reads for InMemory<'_> {
fn issue_trust(&self, _issue: &IssueUrl) -> Result<Option<UntrustedIssue>> {
Ok(None)
}
fn candidate(&self, issue: &IssueUrl) -> Result<Candidate> {
self.seen
.borrow_mut()
.push(Seen::CandidateRead(issue.number));
let facts = self.facts(issue);
Ok(Candidate {
claim_generation: 0,
claimed: false,
parent: facts.and_then(|facts| facts.parent.clone()),
sub_issue_is_open: facts
.map(|facts| facts.sub_issue_is_open.clone())
.unwrap_or_default(),
open_blockers: facts
.map(|facts| facts.open_blockers.clone())
.unwrap_or_default(),
last_shaped: facts.and_then(|facts| facts.last_shaped),
handed_back: facts.is_some_and(|facts| facts.handed_back),
})
}
fn started(&self, issue: &IssueUrl) -> Result<Option<Started>> {
self.seen
.borrow_mut()
.push(Seen::StartedCheck(issue.number));
Ok(self.facts(issue).and_then(|facts| facts.started.clone()))
}
}
fn url(number: u64) -> IssueUrl {
IssueUrl::parse(&format!("https://github.com/acme/widgets/issues/{number}")).unwrap()
}
fn listed(number: u64, more: &[&str]) -> ListedIssue {
ListedIssue {
issue: url(number),
title: format!("Issue {number}"),
labels: [READY_FOR_AGENT.name()]
.iter()
.chain(more)
.copied()
.collect(),
}
}
fn now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 10, 4, 12, 0, 0).unwrap()
}
fn shaped(by: Shaping, ago: TimeDelta) -> Option<Shaped> {
Some(Shaped {
by,
at: now() - ago,
})
}
fn search(
candidates: Vec<ListedIssue>,
facts: Vec<(u64, Facts)>,
) -> (Option<(u64, bool)>, Vec<Seen>) {
let seen = RefCell::new(Vec::new());
let reads = InMemory {
facts: facts.into_iter().collect(),
seen: &seen,
};
let sink = |line| seen.borrow_mut().push(Seen::Line(line));
let ready = Search::new(&reads, candidates, now(), TimeDelta::minutes(30), sink)
.first_ready()
.unwrap()
.map(|ready| (ready.listed.issue.number, ready.is_spec));
(ready, seen.into_inner())
}
fn passed_over(line: &str) -> Seen {
Seen::Line(line.to_string())
}
fn every_read(number: u64) -> [Seen; 2] {
[Seen::CandidateRead(number), Seen::StartedCheck(number)]
}
#[test]
fn an_issue_with_a_label_that_makes_an_unready_ticket_is_passed_over_with_no_reads() {
for (label, named) in [
("ready-for-human", "ready-for-human"),
("needs-info", "needs-info"),
("wontfix", "wontfix"),
("needs-triage", "needs-triage"),
("Needs-Info", "needs-info"),
] {
let (ready, seen) = search(vec![listed(7, &[label])], vec![]);
assert_eq!(ready, None, "{label}");
assert_eq!(
seen,
[passed_over(&format!("#7 labelled {named}"))],
"{label}"
);
}
}
#[test]
fn a_claimed_issue_is_passed_over_with_no_reads() {
let (ready, seen) = search(vec![listed(7, &["in-progress"])], vec![]);
assert_eq!(ready, None);
assert_eq!(seen, [passed_over("#7 labelled in-progress")]);
}
#[test]
fn a_ticket_whose_spec_is_not_listed_names_it_with_no_started_check() {
let ticket = Facts {
parent: Some(url(3)),
..Facts::default()
};
let (ready, seen) = search(vec![listed(7, &[])], vec![(7, ticket)]);
assert_eq!(ready, None);
assert_eq!(
seen,
[
Seen::CandidateRead(7),
passed_over("#7 is a Ticket of #3, which is not ready"),
]
);
}
#[test]
fn a_ticket_of_a_spec_in_another_repository_names_it_with_its_repository() {
let spec = IssueUrl::parse("https://github.com/acme/gadgets/issues/7").unwrap();
let ticket = Facts {
parent: Some(spec),
..Facts::default()
};
let (_, seen) = search(vec![listed(7, &[])], vec![(7, ticket)]);
assert_eq!(
seen,
[
Seen::CandidateRead(7),
passed_over("#7 is a Ticket of acme/gadgets#7, which is not ready"),
]
);
}
#[test]
fn a_ticket_whose_spec_is_listed_but_not_ready_names_it_above_and_below_the_spec() {
let ticket = || Facts {
parent: Some(url(20)),
..Facts::default()
};
let spec = Facts {
sub_issue_is_open: vec![true, true],
open_blockers: vec![30],
..Facts::default()
};
let (ready, seen) = search(
vec![listed(21, &[]), listed(20, &[]), listed(7, &[])],
vec![(7, ticket()), (20, spec), (21, ticket())],
);
assert_eq!(ready, None);
assert_eq!(
seen,
[
Seen::CandidateRead(7),
Seen::CandidateRead(20),
passed_over("#7 is a Ticket of #20, which is not ready"),
passed_over("#20 blocked by #30"),
Seen::CandidateRead(21),
passed_over("#21 is a Ticket of #20, which is not ready"),
]
);
}
#[test]
fn a_ticket_whose_spec_is_a_ready_issue_gets_no_line_above_or_below_the_spec() {
let ticket = || Facts {
parent: Some(url(20)),
..Facts::default()
};
let spec = || Facts {
sub_issue_is_open: vec![true, true],
..Facts::default()
};
let (ready, seen) = search(
vec![listed(7, &[]), listed(20, &[]), listed(21, &[])],
vec![(7, ticket()), (20, spec()), (21, ticket())],
);
assert_eq!(ready, Some((20, true)));
let [candidate_20, started_20] = every_read(20);
assert_eq!(
seen,
[Seen::CandidateRead(7), candidate_20, started_20],
"below"
);
let (ready, seen) = search(
vec![listed(20, &[]), listed(21, &[])],
vec![(20, spec()), (21, ticket())],
);
assert_eq!(ready, Some((20, true)));
assert_eq!(seen, every_read(20), "above");
}
#[test]
fn a_base_fix_issue_is_passed_over_with_no_started_check() {
for label in ["base-fix", "Base-Fix"] {
let (ready, seen) = search(vec![listed(7, &[label])], vec![]);
assert_eq!(ready, None, "{label}");
assert_eq!(
seen,
[Seen::CandidateRead(7), passed_over("#7 labelled base-fix")],
"{label}"
);
}
}
#[test]
fn a_blocked_issue_names_every_open_blocker_with_no_started_check() {
let blocked = Facts {
open_blockers: vec![5, 6],
..Facts::default()
};
let (ready, seen) = search(vec![listed(7, &[])], vec![(7, blocked)]);
assert_eq!(ready, None);
assert_eq!(
seen,
[Seen::CandidateRead(7), passed_over("#7 blocked by #5, #6")]
);
}
#[test]
fn a_never_claimed_started_issue_explains_the_missing_hand_back() {
let pr = "https://github.com/acme/widgets/pull/12";
for (started, said) in [
(
Started::Branch("issue-7-branch-2".to_string()),
"#7 started: issue-7-branch-2 is on origin; no ready-for-agent hand-back after the last Claim ended".to_string(),
),
(
Started::PullRequest(pr.to_string()),
format!("#7 started: PR {pr}; no ready-for-agent hand-back after the last Claim ended"),
),
] {
let facts = Facts {
started: Some(started),
..Facts::default()
};
let (ready, seen) = search(vec![listed(7, &[])], vec![(7, facts)]);
assert_eq!(ready, None);
let [candidate, started] = every_read(7);
assert_eq!(seen, [candidate, started, passed_over(&said)]);
}
}
#[test]
fn a_handed_back_issue_with_a_branch_or_open_pr_is_ready() {
for started in [
Started::Branch("issue-7".to_string()),
Started::PullRequest("https://github.com/acme/widgets/pull/12".to_string()),
] {
let facts = Facts {
started: Some(started),
handed_back: true,
last_shaped: shaped(Shaping::Labelled, TimeDelta::minutes(31)),
..Facts::default()
};
let (ready, _) = search(vec![listed(7, &[])], vec![(7, facts)]);
assert_eq!(ready, Some((7, false)));
}
}
#[test]
fn hand_back_preserves_every_other_readiness_gate_and_the_relabel_settling_window() {
for (labels, parent, blockers, tickets, recent, reason) in [
(
vec!["needs-triage"],
None,
vec![],
vec![],
false,
"labelled needs-triage",
),
(
vec!["in-progress"],
None,
vec![],
vec![],
false,
"labelled in-progress",
),
(
vec!["base-fix"],
None,
vec![],
vec![],
false,
"labelled base-fix",
),
(
vec!["security-fix"],
None,
vec![],
vec![],
false,
"a Security fix: its Security run owns it",
),
(
vec![],
Some(url(3)),
vec![],
vec![],
false,
"is a Ticket of #3, which is not ready",
),
(vec![], None, vec![9], vec![], false, "blocked by #9"),
(
vec![],
None,
vec![],
vec![false],
false,
"every Ticket is closed",
),
(
vec![],
None,
vec![],
vec![],
true,
"not settled: labelled ready-for-agent less than 30 minutes ago",
),
] {
let facts = Facts {
started: Some(Started::Branch("issue-7".to_string())),
handed_back: true,
parent,
open_blockers: blockers,
sub_issue_is_open: tickets,
last_shaped: shaped(
Shaping::Labelled,
TimeDelta::minutes(if recent { 29 } else { 31 }),
),
};
let (ready, seen) = search(vec![listed(7, &labels)], vec![(7, facts)]);
assert_eq!(ready, None, "{reason}");
assert_eq!(seen.last(), Some(&passed_over(&format!("#7 {reason}"))));
}
}
#[test]
fn a_spec_whose_tickets_are_all_closed_is_passed_over_unless_it_was_started() {
let spec = |started: Option<Started>| Facts {
sub_issue_is_open: vec![false, false],
started,
..Facts::default()
};
let (ready, seen) = search(vec![listed(7, &[])], vec![(7, spec(None))]);
assert_eq!(ready, None);
let [candidate, started] = every_read(7);
assert_eq!(
seen,
[candidate, started, passed_over("#7 every Ticket is closed")]
);
let branch = Started::Branch("issue-7".to_string());
let (ready, seen) = search(vec![listed(7, &[])], vec![(7, spec(Some(branch)))]);
assert_eq!(ready, None);
assert_eq!(
seen.last(),
Some(&passed_over(
"#7 started: issue-7 is on origin; no ready-for-agent hand-back after the last Claim ended"
))
);
}
#[test]
fn a_spec_with_a_ticket_still_open_is_a_ready_issue_and_a_spec() {
let spec = Facts {
sub_issue_is_open: vec![false, true],
..Facts::default()
};
let (ready, seen) = search(vec![listed(7, &[])], vec![(7, spec)]);
assert_eq!(ready, Some((7, true)));
assert_eq!(seen, every_read(7));
}
#[test]
fn an_issue_with_no_sub_issues_is_a_ready_issue_and_not_a_spec() {
let (ready, seen) = search(vec![listed(7, &[])], vec![]);
assert_eq!(ready, Some((7, false)));
assert_eq!(seen, every_read(7));
}
#[test]
fn an_issue_settles_thirty_minutes_after_it_was_last_shaped_whatever_shaped_it() {
let wait = TimeDelta::minutes(30);
let just_under = wait - TimeDelta::seconds(1);
let just_over = wait + TimeDelta::seconds(1);
for (by, said) in [
(Shaping::Labelled, "labelled ready-for-agent"),
(Shaping::SubIssues, "a sub-issue added or removed"),
(Shaping::Blockers, "a \"blocked by\" link added or removed"),
] {
for (ago, settled) in [(just_under, false), (wait, true), (just_over, true)] {
let facts = Facts {
last_shaped: shaped(by, ago),
..Facts::default()
};
let (ready, seen) = search(vec![listed(7, &[])], vec![(7, facts)]);
let [candidate, started] = every_read(7);
if settled {
assert_eq!(ready, Some((7, false)), "{said}, {ago}");
assert_eq!(seen, [candidate, started], "{said}, {ago}");
} else {
assert_eq!(ready, None, "{said}, {ago}");
let line =
passed_over(&format!("#7 not settled: {said} less than 30 minutes ago"));
assert_eq!(seen, [candidate, started, line], "{said}, {ago}");
}
}
}
}
#[test]
fn a_passed_over_issue_has_one_line_with_the_first_reason_that_applies() {
let facts = |parent: bool, blocked: bool, started: bool, closed: bool| Facts {
parent: parent.then(|| url(3)),
sub_issue_is_open: if closed { vec![false] } else { vec![] },
open_blockers: if blocked { vec![5] } else { vec![] },
last_shaped: shaped(Shaping::Labelled, TimeDelta::minutes(1)),
started: started.then(|| Started::Branch("issue-7".to_string())),
handed_back: false,
};
for (labels, facts, said) in [
(
&["needs-info", "in-progress", "base-fix"][..],
facts(true, true, true, true),
"#7 labelled needs-info",
),
(
&["in-progress", "base-fix"],
facts(true, true, true, true),
"#7 labelled in-progress",
),
(
&["base-fix"],
facts(true, true, true, true),
"#7 is a Ticket of #3, which is not ready",
),
(
&["base-fix"],
facts(false, true, true, true),
"#7 labelled base-fix",
),
(&[], facts(false, true, true, true), "#7 blocked by #5"),
(
&[],
facts(false, false, true, true),
"#7 started: issue-7 is on origin; no ready-for-agent hand-back after the last Claim ended",
),
(
&[],
facts(false, false, false, true),
"#7 every Ticket is closed",
),
(
&[],
facts(false, false, false, false),
"#7 not settled: labelled ready-for-agent less than 30 minutes ago",
),
] {
let (ready, seen) = search(vec![listed(7, labels)], vec![(7, facts)]);
assert_eq!(ready, None, "{said}");
assert_eq!(seen.last(), Some(&passed_over(said)));
let lines = seen.iter().filter(|seen| matches!(seen, Seen::Line(_)));
assert_eq!(lines.count(), 1, "{said}");
}
}
#[test]
fn each_line_comes_as_its_issue_is_passed_over_and_the_search_stops_at_the_first_ready_issue() {
let started = Facts {
started: Some(Started::Branch("issue-7".to_string())),
..Facts::default()
};
let (ready, seen) = search(
vec![
listed(71, &["needs-info"]),
listed(70, &[]),
listed(9, &["in-progress"]),
listed(8, &["wontfix"]),
listed(7, &[]),
],
vec![(7, started)],
);
assert_eq!(ready, Some((70, false)));
let [candidate_7, started_7] = every_read(7);
let [candidate_70, started_70] = every_read(70);
assert_eq!(
seen,
[
candidate_7,
started_7,
passed_over(
"#7 started: issue-7 is on origin; no ready-for-agent hand-back after the last Claim ended"
),
passed_over("#8 labelled wontfix"),
passed_over("#9 labelled in-progress"),
candidate_70,
started_70,
]
);
}
}