Skip to content

g1t/crates/rules/src/report.rs

107 lines4,412 bytesCodeBlame
1//! What people are told when rules refuse a change: `remote:` lines for
2//! git, and one sentence for the API and the merge box.
3
4use g1t_contracts::rules::Violation;
5
6use crate::outcome::{Judged, blocking};
7
8/// The `ng` reason git prints beside a refused ref: short, one line.
9pub fn ng_reason(judged: &[Judged]) -> String {
10 match blocking(judged).first() {
11 Some(violation) => format!("declined by ruleset \"{}\" ({})", violation.ruleset_name, violation.rule),
12 None => "declined by rules".to_owned(),
13 }
14}
15
16/// The lines git prints as `remote:` for a push the rules refuse: each
17/// broken rule with its ruleset, and how to meet it. `rules_url` is where
18/// the branch's rules are shown.
19pub fn remote_lines(git_ref: &str, judged: &[Judged], rules_url: &str) -> Vec<String> {
20 let violations = blocking(judged);
21 let mut lines = vec![
22 String::new(),
23 format!("error: rules for {git_ref} declined this push:"),
24 ];
25 let mut last_remedy = String::new();
26 for violation in &violations {
27 lines.push(format!("- {} [ruleset \"{}\", {}]", violation.message, violation.ruleset_name, violation.rule));
28 if !violation.remedy.is_empty() && violation.remedy != last_remedy {
29 lines.push(format!(" {}", violation.remedy));
30 last_remedy = violation.remedy.clone();
31 }
32 }
33 lines.push(format!("See the rules that hold for it: {rules_url}"));
34 lines.push(String::new());
35 lines
36}
37
38/// One sentence for a refused merge: the first problem, and how many more.
39pub fn summary(violations: &[&Violation]) -> Option<String> {
40 let first = violations.first()?;
41 let more = violations.len() - 1;
42 let tail = match more {
43 0 => String::new(),
44 1 => " One more rule is not met.".to_owned(),
45 more => format!(" {more} more rules are not met."),
46 };
47 Some(format!("{}{tail}", first.message))
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53 use g1t_contracts::rules::{Enforcement, Level};
54
55 fn judged(violations: &[(&str, &str, &str)]) -> Judged {
56 Judged {
57 id: "rs_1".into(),
58 name: "Protect main".into(),
59 level: Level::Repository,
60 enforcement: Enforcement::Active,
61 bypass: None,
62 git_ref: "refs/heads/main".into(),
63 violations: violations
64 .iter()
65 .map(|(rule, message, remedy)| Violation {
66 rule: (*rule).into(),
67 ruleset_id: "rs_1".into(),
68 ruleset_name: "Protect main".into(),
69 enforcement: Enforcement::Active,
70 message: (*message).into(),
71 remedy: (*remedy).into(),
72 })
73 .collect(),
74 merging: false,
75 }
76 }
77
78 #[test]
79 fn git_is_told_which_ruleset_and_rule_and_what_to_do() {
80 let refused = [judged(&[
81 ("pull_request", "Changes to main must be made through a pull request.", "Push a branch and open a pull request."),
82 ("non_fast_forward", "Force pushes to main are blocked.", "Pull, then push without --force."),
83 ])];
84 assert_eq!(ng_reason(&refused), "declined by ruleset \"Protect main\" (pull_request)");
85 let lines = remote_lines("refs/heads/main", &refused, "https://g1t.sh/acme/web/settings/rules?branch=main");
86 assert_eq!(
87 lines,
88 vec![
89 "",
90 "error: rules for refs/heads/main declined this push:",
91 "- Changes to main must be made through a pull request. [ruleset \"Protect main\", pull_request]",
92 " Push a branch and open a pull request.",
93 "- Force pushes to main are blocked. [ruleset \"Protect main\", non_fast_forward]",
94 " Pull, then push without --force.",
95 "See the rules that hold for it: https://g1t.sh/acme/web/settings/rules?branch=main",
96 "",
97 ]
98 );
99 }
100
101 #[test]
102 fn a_merge_refusal_is_one_sentence() {
103 let refused = judged(&[("pull_request", "It needs 2 approving reviews; it has 0.", ""), ("merge_window", "Merging is frozen.", "")]);
104 assert_eq!(summary(&blocking(&[refused]).to_vec()).as_deref(), Some("It needs 2 approving reviews; it has 0. One more rule is not met."));
105 assert_eq!(summary(&[]), None);
106 }
107}