1use std::fmt;
78
79use serde::Deserialize;
80
81pub mod blank;
82pub mod book;
83pub mod ci;
84pub mod part;
85pub mod terminal;
86pub mod versions;
87pub mod wrap;
88
89#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
91pub struct Milestone {
92 pub title: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
99pub struct PullRequest {
100 pub title: String,
102 pub body: String,
104 pub milestone: Option<Milestone>,
106 #[serde(default)]
109 pub author: Option<Person>,
110 #[serde(default)]
112 pub commits: Vec<PullCommit>,
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
118pub struct Person {
119 #[serde(default)]
121 pub login: String,
122 #[serde(default)]
124 pub name: String,
125 #[serde(default)]
127 pub email: String,
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
134pub struct PullCommit {
135 #[serde(default)]
137 pub authors: Vec<Person>,
138}
139
140impl PullRequest {
141 pub fn new(title: &str, body: &str, milestone: Option<&str>) -> Self {
144 Self {
145 title: title.to_owned(),
146 body: body.to_owned(),
147 milestone: milestone.map(milestone_of),
148 author: None,
149 commits: Vec::new(),
150 }
151 }
152
153 pub fn by(mut self, author: Person) -> Self {
155 self.author = Some(author);
156 self
157 }
158
159 pub fn with_commit_by(mut self, author: Person) -> Self {
161 self.commits.push(PullCommit {
162 authors: vec![author],
163 });
164 self
165 }
166
167 pub fn co_authors(&self) -> Vec<String> {
186 let mut lines: Vec<String> = Vec::new();
187 for person in self.commits.iter().flat_map(|c| &c.authors) {
188 let own = self.author.as_ref().is_some_and(|a| {
189 (!a.login.is_empty() && a.login == person.login)
190 || (!a.email.is_empty() && a.email == person.email)
191 });
192 let name = if person.name.is_empty() {
193 &person.login
194 } else {
195 &person.name
196 };
197 if own || name.is_empty() {
198 continue;
199 }
200 let line = format!("Co-authored-by: {name} <{}>", person.email);
201 if !lines.contains(&line) {
202 lines.push(line);
203 }
204 }
205 lines
206 }
207
208 pub fn squash_message(&self) -> String {
224 self.with_co_authors(merge_wrap(&self.body.replace("\r\n", "\n")))
225 }
226
227 fn written_message(&self) -> String {
230 self.with_co_authors(self.body.replace("\r\n", "\n"))
231 }
232
233 fn with_co_authors(&self, mut message: String) -> String {
235 let co_authors = self.co_authors();
236 if !co_authors.is_empty() {
237 message.truncate(message.trim_end().len());
238 message.push_str("\n\n");
239 message.push_str(&co_authors.join("\n"));
240 message.push('\n');
241 }
242 message
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
248pub struct Issue {
249 pub number: u64,
251 pub state: String,
253 pub milestone: Option<Milestone>,
255}
256
257impl Issue {
258 pub fn new(number: u64, state: &str, milestone: Option<&str>) -> Self {
260 Self {
261 number,
262 state: state.to_owned(),
263 milestone: milestone.map(milestone_of),
264 }
265 }
266}
267
268fn milestone_of(title: &str) -> Milestone {
269 Milestone {
270 title: title.to_owned(),
271 }
272}
273
274pub fn wave_number(title: &str) -> Option<u64> {
288 let rest = title.strip_prefix("Wave ")?;
289 let (number, name) = match rest.split_once(':') {
290 Some((number, name)) => (number, Some(name)),
291 None => (rest, None),
292 };
293 let digits = !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit());
294 let named = name.is_none_or(|n| n.starts_with(' ') && !n.trim().is_empty());
295 (digits && named).then(|| number.parse().ok()).flatten()
296}
297
298pub fn milestone_value(title: &str) -> String {
308 wave_number(title).map_or_else(|| title.to_owned(), |n| format!("Wave {n}"))
309}
310
311pub fn names_milestone(value: &str, title: &str) -> bool {
327 match (wave_number(value), wave_number(title)) {
328 (Some(named), Some(own)) => named == own,
329 _ => value == title,
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct Error {
341 pub rule: &'static str,
343 pub text: String,
345}
346
347impl Error {
348 pub fn new(rule: &'static str, text: impl Into<String>) -> Self {
350 Self {
351 rule,
352 text: text.into(),
353 }
354 }
355}
356
357impl fmt::Display for Error {
358 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359 write!(f, "{}: {}", self.rule, self.text)
360 }
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum Link {
366 Closes(u64),
369 Refs(u64),
371}
372
373impl Link {
374 pub fn issue(self) -> u64 {
376 match self {
377 Self::Closes(n) | Self::Refs(n) => n,
378 }
379 }
380
381 pub fn parse(line: &str) -> Option<Self> {
391 let mut words = line.split_whitespace();
392 let (word, number, None) = (words.next()?, words.next()?, words.next()) else {
393 return None;
394 };
395 let n = issue_ref(number)?;
396 match word {
397 "Closes" => Some(Self::Closes(n)),
398 "Refs" => Some(Self::Refs(n)),
399 _ => None,
400 }
401 }
402}
403
404pub fn issue_ref(word: &str) -> Option<u64> {
412 let digits = word.strip_prefix('#')?;
413 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
414 return None;
415 }
416 digits.parse().ok()
417}
418
419const KEYWORDS: &[&str] = &[
421 "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
422];
423
424pub fn stray_keywords(body: &str) -> Vec<String> {
432 let mut found = Vec::new();
433 for line in body.lines().filter(|l| Link::parse(l).is_none()) {
434 let words: Vec<&str> = line.split_whitespace().collect();
435 for pair in words.windows(2) {
436 let keyword = pair[0].trim_end_matches(':').to_lowercase();
437 if KEYWORDS.contains(&keyword.as_str()) && names_issue(pair[1]) {
438 let number = pair[1].trim_end_matches(|c: char| !c.is_ascii_digit());
439 found.push(format!("{} {number}", pair[0]));
440 }
441 }
442 }
443 found
444}
445
446fn names_issue(word: &str) -> bool {
448 let word = word.trim_end_matches(|c: char| !c.is_ascii_digit());
449 word.rfind('#')
450 .is_some_and(|i| issue_ref(&word[i..]).is_some())
451}
452
453pub fn trailers(text: &str) -> Vec<(String, String)> {
479 trailer_block(text)
480 .into_iter()
481 .filter_map(trailer_line)
482 .collect()
483}
484
485fn trailer_block(text: &str) -> Vec<&str> {
488 let mut paragraphs: Vec<Vec<&str>> = Vec::new();
489 let mut open = false;
490 for line in text.lines().map(str::trim_end) {
491 if line.is_empty() {
492 open = false;
493 } else if open {
494 paragraphs.last_mut().unwrap().push(line);
495 } else {
496 paragraphs.push(vec![line]);
497 open = true;
498 }
499 }
500 let mut found = Vec::new();
501 for paragraph in paragraphs.iter().rev() {
502 if !paragraph.iter().all(|line| trailer_line(line).is_some()) {
503 break;
504 }
505 found.splice(0..0, paragraph.iter().copied());
506 }
507 found
508}
509
510pub const MERGE_WIDTH: usize = 72;
513
514pub fn merge_wrap(text: &str) -> String {
541 let mut wrapped = String::with_capacity(text.len());
542 for (i, line) in text.split('\n').enumerate() {
543 if i > 0 {
544 wrapped.push('\n');
545 }
546 if line.chars().count() <= MERGE_WIDTH {
547 wrapped.push_str(line);
548 continue;
549 }
550 let mut width = 0;
551 for word in line.split_whitespace() {
552 let length = word.chars().count();
553 if width == 0 {
554 } else if width + 1 + length <= MERGE_WIDTH {
556 wrapped.push(' ');
557 width += 1;
558 } else {
559 wrapped.push('\n');
560 width = 0;
561 }
562 wrapped.push_str(word);
563 width += length;
564 }
565 }
566 wrapped
567}
568
569fn wrapped_trailer_lines(text: &str) -> Vec<&str> {
571 trailer_block(text)
572 .into_iter()
573 .filter(|line| line.chars().count() > MERGE_WIDTH)
574 .collect()
575}
576
577fn trailer_line(line: &str) -> Option<(String, String)> {
579 let (key, value) = line.split_once(": ")?;
580 let key_ok = !key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
581 if !key_ok || value.trim().is_empty() {
582 return None;
583 }
584 Some((key.to_owned(), value.trim().to_owned()))
585}
586
587pub fn issue_number(pr: &PullRequest) -> Option<u64> {
597 let body = pr.body.replace("\r\n", "\n");
598 body.lines()
599 .find_map(Link::parse)
600 .map(Link::issue)
601 .or_else(|| {
602 trailers(&body)
603 .iter()
604 .find(|(k, _)| k == "Issue")
605 .and_then(|(_, v)| issue_ref(v))
606 })
607}
608
609fn title_number(title: &str) -> Option<u64> {
611 let (_, tail) = title.trim_end().strip_suffix(')')?.rsplit_once("(#")?;
612 issue_ref(&format!("#{tail}"))
613}
614
615fn values<'a>(trailers: &'a [(String, String)], key: &str) -> Vec<&'a str> {
617 trailers
618 .iter()
619 .filter(|(k, _)| k == key)
620 .map(|(_, v)| v.as_str())
621 .collect()
622}
623
624fn issue_trailer(trailers: &[(String, String)], errors: &mut Vec<Error>) -> Option<u64> {
628 match values(trailers, "Issue")[..] {
629 [] => {
630 errors.push(Error::new(
631 "issue-trailer",
632 "the message does not end with the trailer `Issue: #N`",
633 ));
634 None
635 }
636 [value] => {
637 let n = issue_ref(value);
638 if n.is_none() {
639 errors.push(Error::new(
640 "issue-trailer",
641 format!("`Issue: {value}` does not name an issue as `#N`"),
642 ));
643 }
644 n
645 }
646 ref many => {
647 errors.push(Error::new(
648 "issue-trailer",
649 format!("the message has {} `Issue:` trailers; keep one", many.len()),
650 ));
651 None
652 }
653 }
654}
655
656fn milestone_trailer<'a>(
660 trailers: &'a [(String, String)],
661 errors: &mut Vec<Error>,
662) -> Option<&'a str> {
663 match values(trailers, "Milestone")[..] {
664 [] => {
665 errors.push(Error::new(
666 "milestone-trailer",
667 "the message does not end with the trailer `Milestone: M`",
668 ));
669 None
670 }
671 [value] => Some(value),
672 ref many => {
673 errors.push(Error::new(
674 "milestone-trailer",
675 format!(
676 "the message has {} `Milestone:` trailers; keep one",
677 many.len()
678 ),
679 ));
680 None
681 }
682 }
683}
684
685pub fn check_pr(pr: &PullRequest, issue: Option<&Issue>) -> Vec<Error> {
698 check_pr_for(Rules::All, pr, issue)
699}
700
701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715pub enum Rules {
716 All,
719 Read,
721}
722
723impl Rules {
724 pub fn applies(self, rule: &str) -> bool {
726 match self {
727 Self::All => true,
728 Self::Read => matches!(rule, "link" | "issue-trailer"),
729 }
730 }
731}
732
733pub fn check_pr_for(rules: Rules, pr: &PullRequest, issue: Option<&Issue>) -> Vec<Error> {
754 let mut errors = all_errors(pr, issue);
755 errors.retain(|e| rules.applies(e.rule));
756 errors
757}
758
759fn all_errors(pr: &PullRequest, issue: Option<&Issue>) -> Vec<Error> {
761 let body = pr.body.replace("\r\n", "\n");
762 let mut errors = Vec::new();
763
764 let links: Vec<Link> = body.lines().filter_map(Link::parse).collect();
765 let link = match links[..] {
766 [] => {
767 errors.push(Error::new(
768 "link",
769 "the body has no line `Closes #N` or `Refs #N`",
770 ));
771 None
772 }
773 [link] => Some(link),
774 ref many => {
775 errors.push(Error::new(
776 "link",
777 format!(
778 "the body has {} link lines; keep one: `Closes #N` in the last pull request of the issue, `Refs #N` in each other one",
779 many.len()
780 ),
781 ));
782 None
783 }
784 };
785 for keyword in stray_keywords(&body) {
786 errors.push(Error::new(
787 "keyword",
788 format!("`{keyword}` closes an issue with no check; use the link line"),
789 ));
790 }
791 if let Some(n) = title_number(&pr.title) {
792 errors.push(Error::new(
793 "title",
794 format!("the title ends with `(#{n})`; GitHub adds the number of the pull request"),
795 ));
796 }
797
798 let trailers = trailers(&pr.written_message());
801 let named = issue_trailer(&trailers, &mut errors);
802 if let (Some(named), Some(link)) = (named, link)
803 && named != link.issue()
804 {
805 errors.push(Error::new(
806 "issue-trailer",
807 format!(
808 "`Issue: #{named}` names another issue than the link line (#{})",
809 link.issue()
810 ),
811 ));
812 }
813 let wave = milestone_trailer(&trailers, &mut errors);
814 let pr_milestone = pr.milestone.as_ref().map(|m| m.title.as_str());
815 if let (Some(wave), Some(own)) = (wave, pr_milestone)
816 && !names_milestone(wave, own)
817 {
818 errors.push(Error::new(
819 "milestone-trailer",
820 format!("`Milestone: {wave}` is not the milestone of the pull request ({own})"),
821 ));
822 }
823
824 for line in wrapped_trailer_lines(&body) {
825 errors.push(Error::new(
826 "trailer-wrap",
827 format!(
828 "the trailer line `{line}` has {} characters, and the merge wraps a line over {MERGE_WIDTH}: the wrapped part is no trailer line, and the block of trailers ends there; keep the line to {MERGE_WIDTH} characters",
829 line.chars().count()
830 ),
831 ));
832 }
833
834 if pr_milestone.is_none() {
835 errors.push(Error::new("milestone", "the pull request has no milestone"));
836 }
837 if let Some(issue) = issue {
838 let n = issue.number;
839 match (
840 pr_milestone,
841 issue.milestone.as_ref().map(|m| m.title.as_str()),
842 ) {
843 (_, None) => errors.push(Error::new(
844 "milestone",
845 format!("issue #{n} has no milestone"),
846 )),
847 (Some(own), Some(its)) if own != its => errors.push(Error::new(
848 "milestone",
849 format!("the pull request has milestone `{own}`, and issue #{n} has `{its}`"),
850 )),
851 _ => {}
852 }
853 if issue.state != "OPEN" {
854 errors.push(Error::new("issue-open", format!("issue #{n} is closed")));
855 }
856 }
857 errors
858}
859
860pub fn check_commit(message: &str) -> Vec<Error> {
868 let message = message.replace("\r\n", "\n");
869 let (title, body) = message.split_once('\n').unwrap_or((&message, ""));
870 let mut errors = Vec::new();
871 let trailers = trailers(body);
872 let issue = issue_trailer(&trailers, &mut errors);
873 milestone_trailer(&trailers, &mut errors);
874 match title_number(title) {
875 None => errors.push(Error::new(
876 "commit-title",
877 "the title does not end with `(#PR)`; GitHub adds it at the squash merge of a pull request",
878 )),
879 Some(n) if Some(n) == issue => errors.push(Error::new(
880 "commit-title",
881 format!("the title ends with issue #{n}, not with a pull request"),
882 )),
883 Some(_) => {}
884 }
885 errors
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891
892 const GOOD: &str = "Closes #77\n\nPause the riff.\n\nIssue: #77\nMilestone: Wave 3\n";
893
894 fn pr(body: &str, milestone: Option<&str>) -> PullRequest {
895 PullRequest::new("Pause and resume the riff", body, milestone)
896 }
897
898 fn open(milestone: Option<&str>) -> Issue {
899 Issue::new(77, "OPEN", milestone)
900 }
901
902 fn rules(pr: &PullRequest, issue: &Issue) -> Vec<&'static str> {
904 check_pr(pr, Some(issue)).iter().map(|e| e.rule).collect()
905 }
906
907 #[test]
908 fn a_good_pull_request_passes() {
909 assert_eq!(
910 check_pr(&pr(GOOD, Some("Wave 3")), Some(&open(Some("Wave 3")))),
911 []
912 );
913 let refs = GOOD.replace("Closes #77", "Refs #77");
914 assert_eq!(
915 check_pr(&pr(&refs, Some("Wave 3")), Some(&open(Some("Wave 3")))),
916 []
917 );
918 }
919
920 #[test]
921 fn a_body_from_the_web_with_crlf_passes() {
922 let body = GOOD.replace('\n', "\r\n");
923 assert_eq!(
924 check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3")))),
925 []
926 );
927 }
928
929 #[test]
930 fn other_trailers_may_follow() {
931 let body = format!("{GOOD}Claude-Session: https://claude.ai/code/session_x\n");
932 assert!(rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))).is_empty());
933 }
934
935 #[test]
936 fn no_link_line_fails() {
937 let body = GOOD.replace("Closes #77\n\n", "");
938 let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
939 assert_eq!(
940 errors,
941 [Error::new(
942 "link",
943 "the body has no line `Closes #N` or `Refs #N`"
944 )]
945 );
946 }
947
948 #[test]
949 fn two_link_lines_fail() {
950 let body = GOOD.replace("Closes #77\n", "Closes #77\nRefs #77\n");
951 let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
952 assert_eq!(errors.len(), 1);
953 assert_eq!(errors[0].rule, "link");
954 assert!(
955 errors[0].text.starts_with("the body has 2 link lines"),
956 "{}",
957 errors[0]
958 );
959 }
960
961 #[test]
962 fn another_closing_keyword_fails() {
963 let body = GOOD.replace("Pause the riff.", "It also fixes #12.");
964 let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
965 assert_eq!(
966 errors,
967 [Error::new(
968 "keyword",
969 "`fixes #12` closes an issue with no check; use the link line"
970 )]
971 );
972 }
973
974 #[test]
975 fn a_title_with_a_number_fails() {
976 let mut with_number = pr(GOOD, Some("Wave 3"));
977 with_number.title = "Pause and resume the riff (#77)".into();
978 assert_eq!(rules(&with_number, &open(Some("Wave 3"))), ["title"]);
979 }
980
981 #[test]
982 fn no_issue_trailer_fails() {
983 let body = GOOD.replace("Issue: #77\n", "");
984 let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
985 assert_eq!(
986 errors,
987 [Error::new(
988 "issue-trailer",
989 "the message does not end with the trailer `Issue: #N`"
990 )]
991 );
992 }
993
994 #[test]
995 fn an_issue_trailer_of_another_issue_fails() {
996 let body = GOOD.replace("Issue: #77", "Issue: #78");
997 let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
998 assert_eq!(
999 errors,
1000 [Error::new(
1001 "issue-trailer",
1002 "`Issue: #78` names another issue than the link line (#77)"
1003 )]
1004 );
1005 }
1006
1007 #[test]
1008 fn an_issue_trailer_that_is_not_a_number_fails() {
1009 let body = GOOD.replace("Issue: #77", "Issue: 77");
1010 assert_eq!(
1011 rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1012 ["issue-trailer"]
1013 );
1014 }
1015
1016 #[test]
1017 fn two_issue_trailers_fail() {
1018 let body = GOOD.replace("Issue: #77\n", "Issue: #77\nIssue: #77\n");
1019 assert_eq!(
1020 rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1021 ["issue-trailer"]
1022 );
1023 }
1024
1025 #[test]
1026 fn trailers_in_the_middle_do_not_count() {
1027 let body = format!("{GOOD}\nA last paragraph of text.\n");
1028 assert_eq!(
1029 rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1030 ["issue-trailer", "milestone-trailer"]
1031 );
1032 }
1033
1034 #[test]
1035 fn no_milestone_trailer_fails() {
1036 let body = GOOD.replace("Milestone: Wave 3\n", "");
1037 let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
1038 assert_eq!(
1039 errors,
1040 [Error::new(
1041 "milestone-trailer",
1042 "the message does not end with the trailer `Milestone: M`"
1043 )]
1044 );
1045 }
1046
1047 #[test]
1048 fn a_milestone_trailer_of_another_wave_fails() {
1049 let body = GOOD.replace("Wave 3", "Wave 4");
1050 let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
1051 assert_eq!(
1052 errors,
1053 [Error::new(
1054 "milestone-trailer",
1055 "`Milestone: Wave 4` is not the milestone of the pull request (Wave 3)"
1056 )]
1057 );
1058 }
1059
1060 #[test]
1063 fn a_trailer_of_the_old_title_passes_after_a_rename_of_the_wave() {
1064 let renamed = Some("Wave 28: cloud, fixes that slow the work");
1065 let body = GOOD.replace("Wave 3", "Wave 28: after 1.4.0");
1066 assert_eq!(check_pr(&pr(&body, renamed), Some(&open(renamed))), []);
1067 let number = GOOD.replace("Wave 3", "Wave 28");
1068 assert_eq!(check_pr(&pr(&number, renamed), Some(&open(renamed))), []);
1069
1070 let other = GOOD.replace("Wave 3", "Wave 27: after 1.4.0");
1071 assert_eq!(
1072 check_pr(&pr(&other, renamed), Some(&open(renamed))),
1073 [Error::new(
1074 "milestone-trailer",
1075 "`Milestone: Wave 27: after 1.4.0` is not the milestone of the pull request \
1076 (Wave 28: cloud, fixes that slow the work)"
1077 )]
1078 );
1079 }
1080
1081 #[test]
1082 fn a_milestone_that_is_not_a_wave_needs_its_whole_title() {
1083 let body = GOOD.replace("Wave 3", "Backlog");
1084 assert!(check_pr(&pr(&body, Some("Backlog")), Some(&open(Some("Backlog")))).is_empty());
1085 let body = GOOD.replace("Wave 3", "Backlog 2");
1086 assert_eq!(
1087 rules(&pr(&body, Some("Backlog")), &open(Some("Backlog"))),
1088 ["milestone-trailer"]
1089 );
1090 }
1091
1092 #[test]
1093 fn a_wave_title_has_a_number_and_an_optional_name() {
1094 for (title, number) in [
1095 ("Wave 1", Some(1)),
1096 ("Wave 27: 3.0 Wave A, local sessions", Some(27)),
1097 ("Wave 007", Some(7)),
1098 ("Wave", None),
1099 ("Wave ", None),
1100 ("wave 3", None),
1101 ("Wave 3 ", None),
1102 ("Wave 3:x", None),
1103 ("Wave 3: ", None),
1104 ("Wave 99999999999999999999999", None),
1105 ("Backlog", None),
1106 ] {
1107 assert_eq!(wave_number(title), number, "{title}");
1108 }
1109 }
1110
1111 #[test]
1112 fn a_milestone_of_the_pull_request_that_differs_from_the_issue_fails() {
1113 let errors = check_pr(&pr(GOOD, Some("Wave 3")), Some(&open(Some("Wave 4"))));
1114 assert_eq!(
1115 errors,
1116 [Error::new(
1117 "milestone",
1118 "the pull request has milestone `Wave 3`, and issue #77 has `Wave 4`"
1119 )]
1120 );
1121 }
1122
1123 #[test]
1124 fn no_milestone_fails() {
1125 assert_eq!(rules(&pr(GOOD, None), &open(Some("Wave 3"))), ["milestone"]);
1126 assert_eq!(rules(&pr(GOOD, Some("Wave 3")), &open(None)), ["milestone"]);
1127 }
1128
1129 #[test]
1130 fn a_closed_issue_fails() {
1131 let closed = Issue::new(77, "CLOSED", Some("Wave 3"));
1132 let errors = check_pr(&pr(GOOD, Some("Wave 3")), Some(&closed));
1133 assert_eq!(errors, [Error::new("issue-open", "issue #77 is closed")]);
1134 }
1135
1136 #[test]
1137 fn with_no_issue_only_the_form_is_checked() {
1138 let errors = check_pr(&pr("Text only.", None), None);
1139 let rules: Vec<_> = errors.iter().map(|e| e.rule).collect();
1140 assert_eq!(
1141 rules,
1142 ["link", "issue-trailer", "milestone-trailer", "milestone"]
1143 );
1144 }
1145
1146 #[test]
1147 fn a_good_commit_passes() {
1148 let message = format!("Pause and resume the riff (#90)\n\n{GOOD}");
1149 assert_eq!(check_commit(&message), []);
1150 }
1151
1152 #[test]
1153 fn a_commit_with_no_number_or_trailers_fails() {
1154 let errors = check_commit("Pause and resume the riff\n\nText.\n");
1155 let rules: Vec<_> = errors.iter().map(|e| e.rule).collect();
1156 assert_eq!(
1157 rules,
1158 ["issue-trailer", "milestone-trailer", "commit-title"]
1159 );
1160 }
1161
1162 #[test]
1163 fn a_commit_of_the_old_flow_fails() {
1164 let errors =
1165 check_commit("Pause and resume the riff (#77)\n\nIssue: #77\nMilestone: Wave 3\n");
1166 assert_eq!(
1167 errors,
1168 [Error::new(
1169 "commit-title",
1170 "the title ends with issue #77, not with a pull request"
1171 )]
1172 );
1173 }
1174
1175 #[test]
1176 fn a_commit_with_only_a_title_has_no_trailers() {
1177 let rules: Vec<_> = check_commit("docs: fix a word (#90)")
1178 .iter()
1179 .map(|e| e.rule)
1180 .collect();
1181 assert_eq!(rules, ["issue-trailer", "milestone-trailer"]);
1182 }
1183
1184 const SQUASH: &str = "Make the link test count (#1348)\n\nCloses #692\n\nText.\n\n\
1188 Issue: #692\nMilestone: Wave 41\nGenerated-by: GLM-5.3-Flash\n\n\
1189 Co-authored-by: GLM-5.3-Flash <noreply@z.ai>\n";
1190
1191 #[test]
1192 fn a_commit_with_a_co_author_after_the_trailers_passes() {
1193 for message in [
1194 "T (#9)\n\nIssue: #7\nMilestone: Wave 3\nCo-authored-by: A <a@b.c>\n",
1196 "T (#9)\n\nIssue: #7\nMilestone: Wave 3\n\nCo-authored-by: A <a@b.c>\n",
1198 "T (#9)\n\nSigned-off-by: A <a@b.c>\nIssue: #7\nMilestone: Wave 3\nGenerated-by: X\n",
1200 SQUASH,
1201 ] {
1202 assert_eq!(check_commit(message), [], "{message}");
1203 }
1204 }
1205
1206 #[test]
1207 fn a_commit_with_the_trailers_before_the_last_block_fails() {
1208 for message in [
1209 "T (#9)\n\nIssue: #7\nMilestone: Wave 3\n\nText.\n",
1211 "T (#9)\n\nIssue: #7\nMilestone: Wave 3\n\nText.\n\nCo-authored-by: A <a@b.c>\n",
1213 "T (#9)\n\nIssue: #7\nMilestone: Wave 3\nmore text\n\nCo-authored-by: A <a@b.c>\n",
1215 ] {
1216 let rules: Vec<_> = check_commit(message).iter().map(|e| e.rule).collect();
1217 assert_eq!(rules, ["issue-trailer", "milestone-trailer"], "{message}");
1218 }
1219 }
1220
1221 const BODY_1348: &str = "Closes #692\n\n\
1224 The count check accepts `tries` or `tries + 1` accepts. The call still ends after its budget, with the text `no_reply`, after at least 2 tries, each on a new connection. Nothing else changed.\n\n\
1225 Issue: #692\nMilestone: Wave 41\n\
1226 Generated-by: GLM-5.3-Flash (brief, implementation, task review, final review)\n";
1227
1228 const COMMIT_1348: &str = "The link test counts an accepted connection with no read (#1348)\n\n\
1230 Closes #692\n\n\
1231 The count check accepts `tries` or `tries + 1` accepts. The call still\n\
1232 ends after its budget, with the text `no_reply`, after at least 2 tries,\n\
1233 each on a new connection. Nothing else changed.\n\n\
1234 Issue: #692\nMilestone: Wave 41\n\
1235 Generated-by: GLM-5.3-Flash (brief, implementation, task review, final\nreview)\n\n\
1236 Co-authored-by: GLM-5.3-Flash <noreply@z.ai>\n";
1237
1238 #[test]
1239 fn the_merge_wraps_a_long_line_as_it_did_for_the_commit_1348() {
1240 let pr = PullRequest::new(
1241 "The link test counts an accepted connection with no read",
1242 BODY_1348,
1243 Some("Wave 41"),
1244 )
1245 .with_commit_by(person("GLM-5.3-Flash", "noreply@z.ai"));
1246 let title_and_body = format!("{} (#1348)\n\n{}", pr.title, pr.squash_message());
1247 assert_eq!(title_and_body, COMMIT_1348);
1248 }
1249
1250 #[test]
1251 fn the_commit_1348_fails_the_commit_rule() {
1252 let rules: Vec<_> = check_commit(COMMIT_1348).iter().map(|e| e.rule).collect();
1253 assert_eq!(rules, ["issue-trailer", "milestone-trailer"]);
1254 }
1255
1256 #[test]
1257 fn the_pull_request_1348_fails_with_the_reason_before_the_merge() {
1258 let pr = PullRequest::new(
1259 "The link test counts an accepted connection with no read",
1260 BODY_1348,
1261 Some("Wave 41"),
1262 )
1263 .with_commit_by(person("GLM-5.3-Flash", "noreply@z.ai"));
1264 let issue = Issue::new(692, "OPEN", Some("Wave 41"));
1265 let errors = check_pr(&pr, Some(&issue));
1266 let rules: Vec<_> = errors.iter().map(|e| e.rule).collect();
1267 assert_eq!(rules, ["trailer-wrap"], "{errors:?}");
1268 let text = errors[0].to_string();
1269 assert!(
1270 text.contains("Generated-by: GLM-5.3-Flash (brief"),
1271 "{text}"
1272 );
1273 assert!(text.contains("78 characters"), "{text}");
1274 assert!(text.contains("keep the line to 72 characters"), "{text}");
1275 }
1276
1277 #[test]
1278 fn a_trailer_line_of_the_width_passes_and_one_character_more_fails() {
1279 let value = |n: usize| "x".repeat(n - "Generated-by: ".len());
1280 for (length, rules_found) in [
1281 (MERGE_WIDTH, vec![]),
1282 (MERGE_WIDTH + 1, vec!["trailer-wrap"]),
1283 ] {
1284 let body = format!("{GOOD}Generated-by: {}\n", value(length));
1285 assert_eq!(
1286 rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1287 rules_found,
1288 "{length}"
1289 );
1290 }
1291 }
1292
1293 #[test]
1294 fn each_long_trailer_line_has_an_error() {
1295 let long = "word ".repeat(15);
1296 let body = format!("{GOOD}Generated-by: {long}\nSigned-off-by: {long}\n");
1297 assert_eq!(
1298 rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1299 ["trailer-wrap", "trailer-wrap"]
1300 );
1301 }
1302
1303 #[test]
1304 fn a_long_line_of_text_is_no_error() {
1305 let text = format!(
1306 "Closes #77\n\n{}\n\nIssue: #77\nMilestone: Wave 3\n",
1307 "word ".repeat(30)
1308 );
1309 assert_eq!(
1310 check_pr(&pr(&text, Some("Wave 3")), Some(&open(Some("Wave 3")))),
1311 []
1312 );
1313 }
1314
1315 #[test]
1316 fn the_merge_leaves_a_line_of_the_width_and_the_lines_after_it() {
1317 assert_eq!(merge_wrap(GOOD), GOOD);
1318 let kept = format!("{}\n indent two\n", "word ".repeat(14) + "wo");
1319 assert_eq!(merge_wrap(&kept), kept);
1320 }
1321
1322 #[test]
1323 fn the_merge_fills_a_long_line_again_with_whole_words() {
1324 let line = format!(" {}", "word ".repeat(30));
1325 let wrapped = merge_wrap(&line);
1326 assert!(wrapped.lines().count() > 1);
1327 for wrapped_line in wrapped.lines() {
1328 assert!(
1329 wrapped_line.chars().count() <= MERGE_WIDTH,
1330 "{wrapped_line}"
1331 );
1332 assert!(wrapped_line.starts_with("word") && wrapped_line.ends_with("word"));
1333 }
1334 assert_eq!(wrapped.split_whitespace().count(), 30);
1335 let url = "u".repeat(80);
1337 assert_eq!(
1338 merge_wrap(&format!("see {url} here")),
1339 format!("see\n{url}\nhere")
1340 );
1341 }
1342
1343 #[test]
1344 fn the_trailers_of_a_block_of_two_paragraphs_keep_their_order() {
1345 let found =
1346 trailers("Text.\n\nIssue: #7\nMilestone: Wave 3\n\nCo-authored-by: A <a@b.c>\n");
1347 let keys: Vec<_> = found.iter().map(|(k, _)| k.as_str()).collect();
1348 assert_eq!(keys, ["Issue", "Milestone", "Co-authored-by"]);
1349 }
1350
1351 #[test]
1352 fn a_message_of_one_paragraph_of_text_has_no_trailer() {
1353 assert!(trailers("").is_empty());
1354 assert!(trailers("Text.\n").is_empty());
1355 assert!(trailers("Text.\n\nMore.\n").is_empty());
1356 }
1357
1358 fn person(name: &str, email: &str) -> Person {
1359 Person {
1360 name: name.to_owned(),
1361 email: email.to_owned(),
1362 ..Person::default()
1363 }
1364 }
1365
1366 fn glm() -> Person {
1367 person("GLM-5.3-Flash", "noreply@z.ai")
1368 }
1369
1370 #[test]
1371 fn a_pull_request_with_a_co_author_passes() {
1372 let pr = pr(GOOD, Some("Wave 3")).with_commit_by(glm());
1373 assert_eq!(
1374 pr.co_authors(),
1375 ["Co-authored-by: GLM-5.3-Flash <noreply@z.ai>"]
1376 );
1377 assert_eq!(check_pr(&pr, Some(&open(Some("Wave 3")))), []);
1378 }
1379
1380 #[test]
1381 fn a_pull_request_with_a_body_that_ends_with_text_fails_also_with_a_co_author() {
1382 let body = format!("{GOOD}\nA last paragraph of text.\n");
1383 let pr = pr(&body, Some("Wave 3")).with_commit_by(glm());
1384 assert_eq!(
1385 rules(&pr, &open(Some("Wave 3"))),
1386 ["issue-trailer", "milestone-trailer"]
1387 );
1388 }
1389
1390 #[test]
1391 fn the_author_of_the_pull_request_is_no_co_author() {
1392 let ana = Person {
1393 login: "ana".to_owned(),
1394 ..person("Ana", "ana@example.com")
1395 };
1396 let pr = pr(GOOD, Some("Wave 3"))
1397 .by(ana.clone())
1398 .with_commit_by(ana)
1399 .with_commit_by(glm())
1400 .with_commit_by(glm());
1401 assert_eq!(pr.co_authors().len(), 1);
1402 assert_eq!(
1403 pr.squash_message(),
1404 format!("{GOOD}\nCo-authored-by: GLM-5.3-Flash <noreply@z.ai>\n")
1405 );
1406 }
1407
1408 #[test]
1409 fn a_pull_request_with_no_co_author_has_its_body_as_message() {
1410 assert_eq!(pr(GOOD, None).squash_message(), GOOD);
1411 let web = GOOD.replace('\n', "\r\n");
1412 assert_eq!(pr(&web, None).squash_message(), GOOD);
1413 }
1414
1415 #[test]
1416 fn gh_json_of_a_pull_request_with_commits_has_its_co_author() {
1417 let json = r#"{
1418 "title": "T", "body": "Closes #1\n\nIssue: #1\nMilestone: Wave 3",
1419 "milestone": {"title": "Wave 3"},
1420 "author": {"id": "x", "is_bot": false, "login": "ana", "name": "Ana"},
1421 "commits": [
1422 {"oid": "a", "authors": [{"email": "ana@example.com", "id": "x", "login": "ana", "name": "Ana"}]},
1423 {"oid": "b", "authors": [
1424 {"email": "ana@example.com", "id": "x", "login": "ana", "name": "Ana"},
1425 {"email": "noreply@z.ai", "id": "", "login": "", "name": "GLM-5.3-Flash"}
1426 ]}
1427 ]
1428 }"#;
1429 let pr: PullRequest = serde_json::from_str(json).unwrap();
1430 assert_eq!(
1431 pr.co_authors(),
1432 ["Co-authored-by: GLM-5.3-Flash <noreply@z.ai>"]
1433 );
1434 assert_eq!(check_pr(&pr, Some(&open(Some("Wave 3")))), []);
1435 }
1436
1437 #[test]
1438 fn the_rules_that_riff_reads_pass_a_pull_request_of_another_habit() {
1439 let plain = PullRequest::new(
1440 "Fix it (#77)",
1441 "Closes #77\n\nFixes #78.\n\nIssue: #77\n",
1442 None,
1443 );
1444 let issue = Issue::new(77, "CLOSED", None);
1445 assert_eq!(check_pr_for(Rules::Read, &plain, Some(&issue)), []);
1446 assert!(!check_pr_for(Rules::All, &plain, Some(&issue)).is_empty());
1447 }
1448
1449 #[test]
1450 fn the_rules_that_riff_reads_find_a_missing_link_and_a_wrong_issue_line() {
1451 let rules = |body: &str| -> Vec<&'static str> {
1452 check_pr_for(Rules::Read, &pr(body, None), None)
1453 .iter()
1454 .map(|e| e.rule)
1455 .collect()
1456 };
1457 assert_eq!(rules("Text.\n\nIssue: #77\n"), ["link"]);
1458 assert_eq!(rules("Closes #77\n\nText.\n"), ["issue-trailer"]);
1459 assert_eq!(rules("Closes #77\n\nIssue: #9\n"), ["issue-trailer"]);
1460 assert!(rules("Refs #77\n\nIssue: #77\n").is_empty());
1461 }
1462}