src/depsets.rs
//! **Dependency sets** (holger lane HOL-DEPSET-APPROVE): a pull's whole closure, which
//! holger offers its approvers as ONE item. The console only shows what holger serves at
//! `GET /-/intake/sets` and sends the two verbs (`POST /-/intake/set/approve` and
//! `/-/intake/set/refuse`); holger decides who may, what the set's status is, which member
//! refuses or holds it, and the two-person rule. Every word on the card is holger's.
//!
//! Pure but for the two fetches (wasm only): host-tested against holger's answers.
// The console's pages are wasm only; on the host these are used by the tests alone.
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
use crate::util::{enc, Tone};
/// holger's list of the sets the caller may read.
pub const SETS_PATH: &str = "/-/intake/sets";
/// One member of a set, as holger answered it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MemberRow {
pub name: String,
/// holger's word for why the member is in the set (`requested`, `parent`, `descriptor` …).
pub part: String,
pub state: String,
pub tone: Tone,
/// holger's reason, when the member refuses or holds the set.
pub reason: String,
/// The advisories holger recorded as warnings (a Maven POM's).
pub warnings: String,
pub votes: String,
}
/// One set as a card.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SetCard {
pub id: String,
/// The set's revision as holger served it: what a vote on this card names (SEC-REVIEW
/// D4), so holger refuses the vote when the set changed since the page drew it.
pub revision: String,
pub root: String,
pub ecosystem: String,
pub source: String,
pub status: String,
pub tone: Tone,
pub summary: String,
/// `1 of 2 approvers approved the whole set` (holger's counts).
pub votes: String,
/// Who voted on the whole set, as holger listed them.
pub voted: String,
/// `member: why` — what refuses the set.
pub blockers: Vec<String>,
/// `member: why` — what holds the set.
pub holds: Vec<String>,
pub notes: Vec<String>,
pub members: Vec<MemberRow>,
/// `221 members: 170 POMs …` — the count line.
pub count: String,
/// The set waits for its approvers: the two buttons are drawn (holger still decides).
pub open: bool,
}
/// Pure: the badge tone of a set status holger sent.
pub fn status_tone(status: &str) -> Tone {
match status {
"available" | "approved" => Tone::Good,
"awaiting-approval" | "resolving" | "held" => Tone::Warn,
"refused" => Tone::Bad,
_ => Tone::Neutral,
}
}
/// Pure: the badge tone of a member state holger sent (`policy-checked`, `blocked` …).
pub fn member_tone(state: &str) -> Tone {
match state {
"available" | "approved" | "sealed" | "promoted" => Tone::Good,
"policy-checked" | "quarantined" | "not-in-chain" => Tone::Warn,
"blocked" => Tone::Bad,
_ => Tone::Neutral,
}
}
fn s(v: &serde_json::Value, k: &str) -> String {
v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_owned()
}
fn n(v: &serde_json::Value, k: &str) -> u64 {
v.get(k).and_then(|x| x.as_u64()).unwrap_or(0)
}
fn pairs(v: &serde_json::Value, k: &str) -> Vec<String> {
v.get(k)
.and_then(|x| x.as_array())
.into_iter()
.flatten()
.filter_map(|p| {
let a = p.as_array()?;
Some(format!("{}: {}", a.first()?.as_str()?, a.get(1)?.as_str().unwrap_or("")))
})
.collect()
}
fn strings(v: &serde_json::Value, k: &str) -> Vec<String> {
v.get(k)
.and_then(|x| x.as_array())
.into_iter()
.flatten()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
}
/// Pure: one set holger served, as a card.
pub fn card(v: &serde_json::Value) -> SetCard {
let status = s(v, "status");
let required = n(v, "approvals_required");
let approvers = strings(v, "approvers");
let members: Vec<MemberRow> = v
.get("members")
.and_then(|m| m.as_array())
.into_iter()
.flatten()
.map(|m| {
let state = s(m, "state");
MemberRow {
tone: member_tone(&state),
name: s(m, "name"),
part: s(m, "role"),
state,
reason: s(m, "reason"),
warnings: strings(m, "warnings").join(", "),
votes: if n(m, "approvals") > 0 {
format!("{} of {required}", n(m, "approvals"))
} else {
String::new()
},
}
})
.collect();
let mut roles: Vec<(String, usize)> = Vec::new();
for m in &members {
match roles.iter_mut().find(|(r, _)| *r == m.part) {
Some((_, c)) => *c += 1,
None => roles.push((m.part.clone(), 1)),
}
}
let count = format!(
"{} member{}{}",
members.len(),
if members.len() == 1 { "" } else { "s" },
if roles.is_empty() {
String::new()
} else {
format!(": {}", roles.iter().map(|(r, c)| format!("{c} {r}")).collect::<Vec<_>>().join(", "))
}
);
SetCard {
id: s(v, "id"),
revision: s(v, "revision"),
root: s(v, "root"),
ecosystem: s(v, "ecosystem"),
source: s(v, "source"),
tone: status_tone(&status),
open: status == "awaiting-approval",
status,
summary: s(v, "summary"),
votes: format!("{} of {required} approvers approved the whole set", approvers.len()),
voted: approvers.join(", "),
blockers: pairs(v, "blockers"),
holds: pairs(v, "holds"),
notes: strings(v, "notes"),
members,
count,
}
}
/// Pure: holger's answer to `GET /-/intake/sets` as cards, as it sent them.
pub fn cards(body: &str) -> Result<Vec<SetCard>, String> {
let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("holger's answer is not JSON: {e}"))?;
Ok(v.get("sets").and_then(|x| x.as_array()).into_iter().flatten().map(card).collect())
}
/// Pure: the path of a set verb (`approve` or `refuse`) with the revision the card showed and
/// the reason.
pub fn verb_path(verb: &str, id: &str, revision: &str, reason: &str) -> String {
format!("/-/intake/set/{verb}?id={}&revision={}&reason={}", enc(id), enc(revision), enc(reason))
}
/// Pure: what a verb's answer says: holger's message, or its refusal.
pub fn said(status: u16, body: &str) -> Result<String, String> {
if status == 200 {
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
Ok(v.get("message").and_then(|m| m.as_str()).unwrap_or("done").to_owned())
} else {
Err(crate::session::refusal_words(status, body))
}
}
/// The sets the caller may read (wasm only).
#[cfg(target_arch = "wasm32")]
pub async fn load() -> Result<Vec<SetCard>, String> {
let r = gloo_net::http::Request::get(SETS_PATH)
.send()
.await
.map_err(|e| format!("holger did not answer: {e}"))?;
let status = r.status();
let body = r.text().await.unwrap_or_default();
if status != 200 {
return Err(crate::session::refusal_words(status, &body));
}
cards(&body)
}
/// Send one verb (wasm only): holger's message or its refusal.
#[cfg(target_arch = "wasm32")]
pub async fn press(verb: &str, id: &str, revision: &str, reason: &str) -> Result<String, String> {
let r = gloo_net::http::Request::post(&verb_path(verb, id, revision, reason))
.send()
.await
.map_err(|e| format!("holger did not answer: {e}"))?;
let status = r.status();
said(status, &r.text().await.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
/// holger's answer as `GET /-/intake/sets` serves it (the shape of
/// holger-server-lib `pull_intake::depset::SetView`).
const SETS: &str = r#"{"approvals_required":2,"sets":[
{"id":"4f1c2a9e0b3d7c11","source":"central","ecosystem":"maven",
"root":"org.apache.maven.plugins:maven-dependency-plugin:3.7.0","at":1,
"status":"awaiting-approval",
"summary":"221 of 221 member(s) wait for approval; 1 of 2 approver(s) approved the whole set",
"members":[
{"name":"central/org/apache/maven/plugins/maven-dependency-plugin/3.7.0/maven-dependency-plugin-3.7.0.pom","role":"requested","via":"","state":"policy-checked","sha256":"aa","approvals":1,"approvers":["bob"],"reason":"","warnings":[]},
{"name":"central/commons-collections/commons-collections/3.2.1/commons-collections-3.2.1.pom","role":"descriptor","via":"x","state":"policy-checked","sha256":"bb","approvals":1,"approvers":["bob"],"reason":"","warnings":["GHSA-fjq5-5j5f-mvxh (critical)"]}
],
"blockers":[],"holds":[],"approvals_required":2,"approvers":["bob"],"awaiting":2,"available":0,"notes":[]},
{"id":"77","source":"central","ecosystem":"maven","root":"org.example:bad:1.0","at":2,
"status":"refused","summary":"refused: 1 of 2 member(s) carry a refusal reason",
"members":[{"name":"central/org/example/bad/1.0/bad-1.0.jar","role":"requested","via":"","state":"blocked","sha256":"cc","approvals":0,"approvers":[],"reason":"GHSA-x critical","warnings":[]}],
"blockers":[["central/org/example/bad/1.0/bad-1.0.jar","GHSA-x critical"]],"holds":[],"approvals_required":2,"approvers":[],"awaiting":0,"available":0,"notes":["an optional dependency was left out"]}
]}"#;
#[test]
fn a_set_is_one_card_with_its_members_as_holger_sent_them() {
let c = cards(SETS).unwrap();
assert_eq!(c.len(), 2);
let plugin = &c[0];
assert_eq!(plugin.root, "org.apache.maven.plugins:maven-dependency-plugin:3.7.0");
assert_eq!(plugin.status, "awaiting-approval");
assert_eq!(plugin.tone, Tone::Warn);
assert!(plugin.open, "a waiting set draws its two buttons");
assert_eq!(plugin.votes, "1 of 2 approvers approved the whole set");
assert_eq!(plugin.revision, "", "a set holger served without a revision has none to name");
assert_eq!(plugin.voted, "bob");
assert_eq!(plugin.count, "2 members: 1 requested, 1 descriptor");
assert_eq!(plugin.members[1].warnings, "GHSA-fjq5-5j5f-mvxh (critical)");
assert_eq!(plugin.members[1].votes, "1 of 2");
assert_eq!(plugin.summary, "221 of 221 member(s) wait for approval; 1 of 2 approver(s) approved the whole set");
let bad = &c[1];
assert!(!bad.open, "a refused set draws no approve button");
assert_eq!(bad.tone, Tone::Bad);
assert_eq!(bad.blockers, vec!["central/org/example/bad/1.0/bad-1.0.jar: GHSA-x critical".to_string()]);
assert_eq!(bad.members[0].tone, Tone::Bad);
assert_eq!(bad.notes, vec!["an optional dependency was left out".to_string()]);
}
#[test]
fn a_verb_sends_the_set_and_the_reason_and_shows_holgers_words() {
assert_eq!(
verb_path("approve", "4f1c", "aaaabbbbccccdddd", "looked at it"),
"/-/intake/set/approve?id=4f1c&revision=aaaabbbbccccdddd&reason=looked%20at%20it"
);
assert_eq!(said(200, r#"{"message":"bob approved dependency set 4f1c","moved":221}"#), Ok("bob approved dependency set 4f1c".into()));
assert_eq!(
said(409, "[holger-depset-2] dependency set 77 cannot be approved: bad-1.0.jar: GHSA-x"),
Err("[holger-depset-2] dependency set 77 cannot be approved: bad-1.0.jar: GHSA-x".into())
);
}
#[test]
fn an_answer_that_is_not_json_is_said() {
assert!(cards("nope").is_err());
assert_eq!(cards(r#"{"sets":[]}"#).unwrap(), vec![]);
}
}