Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

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![]);
    }
}