use std::collections::HashMap;
use super::heal_claims::Attempts;
use super::heal_intake::{ChecksState, HealTarget, RawPullRequest};
use super::provenance::ProvenanceTier;
pub const CLAIM_TTL_MS: u64 = 90 * 60 * 1000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
pub repo: String,
pub number: u64,
pub tier: Option<ProvenanceTier>,
pub labelled: bool,
pub created_ms: u64,
pub kind: CandidateKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateKind {
Issue,
FailingPullRequest,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Claim {
pub run_id: String,
pub claimed_ms: u64,
}
impl Claim {
fn is_live(&self, now_ms: u64) -> bool {
now_ms.saturating_sub(self.claimed_ms) < CLAIM_TTL_MS
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "skip", rename_all = "snake_case")]
pub enum Skip {
UntrustedAuthor { tier: ProvenanceTier },
NotLabelled,
WatchOnly,
Claimed { run_id: String },
AlreadyCovered { pr_number: u64 },
IntentNotCleared,
Gone,
RecentlyFailed {
attempts: u32,
next_eligible_ms: Option<u64>,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SkippedItem {
pub repo: String,
pub number: u64,
pub reason: Skip,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selection {
pub chosen: Option<Candidate>,
pub skipped: Vec<SkippedItem>,
}
pub fn select(
target: &HealTarget,
candidates: &[Candidate],
open_prs: &[RawPullRequest],
claims: &HashMap<String, Claim>,
attempts: &HashMap<String, Attempts>,
now_ms: u64,
) -> Selection {
let mut skipped = Vec::new();
let mut eligible: Vec<&Candidate> = Vec::new();
for c in candidates {
if let Some(reason) = ineligible(target, c, open_prs, claims, attempts, now_ms) {
skipped.push(SkippedItem {
repo: c.repo.clone(),
number: c.number,
reason,
});
continue;
}
eligible.push(c);
}
eligible.sort_by_key(|c| (c.created_ms, c.number));
Selection {
chosen: eligible.first().map(|c| (*c).clone()),
skipped,
}
}
fn ineligible(
target: &HealTarget,
c: &Candidate,
open_prs: &[RawPullRequest],
claims: &HashMap<String, Claim>,
attempts: &HashMap<String, Attempts>,
now_ms: u64,
) -> Option<Skip> {
if let Some(tier) = c.tier {
if !tier.may_seed_session() {
return Some(Skip::UntrustedAuthor { tier });
}
}
if !c.labelled {
return Some(Skip::NotLabelled);
}
if !target.can_write() {
return Some(Skip::WatchOnly);
}
if let Some(claim) = claims.get(&claim_key(&c.repo, c.number)) {
if claim.is_live(now_ms) {
return Some(Skip::Claimed {
run_id: claim.run_id.clone(),
});
}
}
if let Some(a) = attempts.get(&claim_key(&c.repo, c.number)) {
if !a.ready(now_ms) {
return Some(Skip::RecentlyFailed {
attempts: a.count,
next_eligible_ms: a.next_eligible_ms(),
reason: a.last_reason.clone(),
});
}
}
if c.kind == CandidateKind::Issue {
let tracker = target.is_cross_repo().then_some(target.repo.as_str());
if let Some(pr) = open_prs.iter().find(|pr| pr.references(tracker, c.number)) {
return Some(Skip::AlreadyCovered {
pr_number: pr.number(),
});
}
}
None
}
pub fn claim_key(repo: &str, number: u64) -> String {
format!("{repo}#{number}")
}
pub fn pr_needs_work(pr: &RawPullRequest) -> bool {
if pr.is_draft() {
return false;
}
matches!(pr.checks(), ChecksState::Failing) || pr.changes_requested()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coder::heal_intake::Checkout;
fn target() -> HealTarget {
HealTarget {
repo: "acme/widgets".into(),
fix_repo: None,
checkout: Some(Checkout::Project("widgets".into())),
label: "self-heal".into(),
base: "main".into(),
}
}
fn untiered(number: u64, labelled: bool) -> Candidate {
Candidate {
repo: "acme/widgets".into(),
number,
tier: None,
labelled,
created_ms: number,
kind: CandidateKind::Issue,
}
}
#[test]
fn an_untiered_candidate_is_not_rejected_as_untrusted() {
let out = select(
&target(),
&[untiered(7, true)],
&[],
&HashMap::new(),
&HashMap::new(),
0,
);
assert_eq!(out.chosen.map(|c| c.number), Some(7));
assert!(out.skipped.is_empty());
}
#[test]
fn an_untiered_candidate_is_still_subject_to_every_other_check() {
let out = select(
&target(),
&[untiered(7, false)],
&[],
&HashMap::new(),
&HashMap::new(),
0,
);
assert!(out.chosen.is_none());
assert_eq!(out.skipped[0].reason, Skip::NotLabelled);
}
#[test]
fn a_resolved_denial_still_wins_over_every_cheaper_check() {
let mut c = candidate(7, 1, ProvenanceTier::Public);
c.labelled = false;
let out = select(&target(), &[c], &[], &HashMap::new(), &HashMap::new(), 0);
assert_eq!(
out.skipped[0].reason,
Skip::UntrustedAuthor {
tier: ProvenanceTier::Public
}
);
}
fn candidate(number: u64, created_ms: u64, tier: ProvenanceTier) -> Candidate {
Candidate {
repo: "acme/widgets".into(),
number,
tier: Some(tier),
labelled: true,
created_ms,
kind: CandidateKind::Issue,
}
}
fn pr_with(number: u64, body: &str) -> RawPullRequest {
RawPullRequest::new(
"acme/widgets",
number,
"someone",
"a pull request",
body,
vec![],
ChecksState::Passing,
"",
false,
)
}
#[test]
fn an_empty_queue_selects_nothing() {
let s = select(&target(), &[], &[], &HashMap::new(), &HashMap::new(), 0);
assert_eq!(s.chosen, None, "idle is the steady state, not an error");
assert!(s.skipped.is_empty());
}
#[test]
fn a_public_author_can_never_seed_a_session() {
let c = candidate(1, 0, ProvenanceTier::Public);
let s = select(&target(), &[c], &[], &HashMap::new(), &HashMap::new(), 0);
assert_eq!(s.chosen, None);
assert_eq!(
s.skipped[0].reason,
Skip::UntrustedAuthor {
tier: ProvenanceTier::Public
}
);
}
#[test]
fn authorisation_is_checked_before_anything_cheaper() {
let mut c = candidate(1, 0, ProvenanceTier::Public);
c.labelled = false;
let s = select(&target(), &[c], &[], &HashMap::new(), &HashMap::new(), 0);
assert!(matches!(s.skipped[0].reason, Skip::UntrustedAuthor { .. }));
}
#[test]
fn an_unlabelled_item_is_invisible() {
let mut c = candidate(1, 0, ProvenanceTier::Maintainer);
c.labelled = false;
let s = select(&target(), &[c], &[], &HashMap::new(), &HashMap::new(), 0);
assert_eq!(s.chosen, None);
assert_eq!(s.skipped[0].reason, Skip::NotLabelled);
}
#[test]
fn a_maintainer_issue_is_eligible() {
let c = candidate(1, 0, ProvenanceTier::Maintainer);
let s = select(
&target(),
std::slice::from_ref(&c),
&[],
&HashMap::new(),
&HashMap::new(),
0,
);
assert_eq!(s.chosen, Some(c));
}
#[test]
fn a_watch_only_target_selects_nothing() {
let mut t = target();
t.checkout = None;
let c = candidate(1, 0, ProvenanceTier::Maintainer);
let s = select(&t, &[c], &[], &HashMap::new(), &HashMap::new(), 0);
assert_eq!(s.chosen, None);
assert_eq!(s.skipped[0].reason, Skip::WatchOnly);
}
#[test]
fn oldest_first_and_stable_when_timestamps_tie() {
let a = candidate(9, 100, ProvenanceTier::Maintainer);
let b = candidate(2, 100, ProvenanceTier::Maintainer);
let older = candidate(5, 50, ProvenanceTier::Maintainer);
let s = select(
&target(),
&[a, b.clone(), older.clone()],
&[],
&HashMap::new(),
&HashMap::new(),
0,
);
assert_eq!(s.chosen, Some(older), "oldest wins");
let s2 = select(
&target(),
&[candidate(9, 100, ProvenanceTier::Maintainer), b.clone()],
&[],
&HashMap::new(),
&HashMap::new(),
0,
);
assert_eq!(s2.chosen, Some(b));
}
#[test]
fn a_live_claim_holds_the_item() {
let c = candidate(1, 0, ProvenanceTier::Maintainer);
let mut claims = HashMap::new();
claims.insert(
claim_key("acme/widgets", 1),
Claim {
run_id: "run-a".into(),
claimed_ms: 1_000,
},
);
let s = select(
&target(),
&[c],
&[],
&claims,
&HashMap::new(),
1_000 + CLAIM_TTL_MS - 1,
);
assert_eq!(s.chosen, None);
assert_eq!(
s.skipped[0].reason,
Skip::Claimed {
run_id: "run-a".into()
}
);
}
#[test]
fn an_expired_claim_releases_the_item() {
let c = candidate(1, 0, ProvenanceTier::Maintainer);
let mut claims = HashMap::new();
claims.insert(
claim_key("acme/widgets", 1),
Claim {
run_id: "dead-run".into(),
claimed_ms: 1_000,
},
);
let s = select(
&target(),
std::slice::from_ref(&c),
&[],
&claims,
&HashMap::new(),
1_000 + CLAIM_TTL_MS,
);
assert_eq!(s.chosen, Some(c));
}
#[test]
fn an_issue_already_covered_by_a_pull_request_is_skipped() {
let c = candidate(42, 0, ProvenanceTier::Maintainer);
let prs = vec![pr_with(7, "fixes #42")];
let s = select(&target(), &[c], &prs, &HashMap::new(), &HashMap::new(), 0);
assert_eq!(s.chosen, None);
assert_eq!(s.skipped[0].reason, Skip::AlreadyCovered { pr_number: 7 });
}
#[test]
fn a_pull_request_for_a_different_issue_does_not_cover_this_one() {
let c = candidate(42, 0, ProvenanceTier::Maintainer);
let prs = vec![pr_with(7, "fixes #43")];
let s = select(
&target(),
std::slice::from_ref(&c),
&prs,
&HashMap::new(),
&HashMap::new(),
0,
);
assert_eq!(s.chosen, Some(c));
}
#[test]
fn coverage_does_not_apply_to_a_failing_pull_request() {
let mut c = candidate(7, 0, ProvenanceTier::Maintainer);
c.kind = CandidateKind::FailingPullRequest;
let prs = vec![pr_with(7, "this is the pull request itself, #7")];
let s = select(
&target(),
std::slice::from_ref(&c),
&prs,
&HashMap::new(),
&HashMap::new(),
0,
);
assert_eq!(s.chosen, Some(c));
}
#[test]
fn a_draft_is_never_work() {
let pr = RawPullRequest::new(
"acme/widgets",
1,
"someone",
"wip",
"",
vec![],
ChecksState::Failing,
"",
true,
);
assert!(!pr_needs_work(&pr), "a draft has not asked for review");
}
#[test]
fn pending_checks_are_not_failure() {
let pr = RawPullRequest::new(
"acme/widgets",
1,
"someone",
"t",
"",
vec![],
ChecksState::Pending,
"",
false,
);
assert!(!pr_needs_work(&pr));
}
#[test]
fn failing_checks_or_requested_changes_are_work() {
let failing = RawPullRequest::new(
"acme/widgets",
1,
"s",
"t",
"",
vec![],
ChecksState::Failing,
"",
false,
);
assert!(pr_needs_work(&failing));
let changes = RawPullRequest::new(
"acme/widgets",
2,
"s",
"t",
"",
vec![],
ChecksState::Passing,
"CHANGES_REQUESTED",
false,
);
assert!(pr_needs_work(&changes));
}
#[test]
fn every_skip_is_recorded_so_an_idle_loop_can_be_explained() {
let mut public = candidate(1, 0, ProvenanceTier::Public);
public.labelled = true;
let mut unlabelled = candidate(2, 0, ProvenanceTier::Maintainer);
unlabelled.labelled = false;
let s = select(
&target(),
&[public, unlabelled],
&[],
&HashMap::new(),
&HashMap::new(),
0,
);
assert_eq!(s.chosen, None);
assert_eq!(s.skipped.len(), 2, "silence would be unexplainable");
}
}