gunnar_core/forge.rs
1//! **The review half of a pull request: its evidence** (roadmap phase 1).
2//!
3//! Comments and approvals, as **signed append-only events**.
4//!
5//! The roadmap's claim about this phase is not "gunnar grows issues". It is:
6//!
7//! > Every forge event … is an sshsig-signed row under the principal's own key.
8//! > An approval therefore carries the same evidential weight as a commit — and
9//! > becomes a link in the chain: the artifact can show who approved the change.
10//!
11//! That is what this module is. An approval signed by the reviewer's own key is
12//! evidence in exactly the sense a signed commit is: it can be checked by
13//! anybody, later, without trusting the server that stored it. A row in a table
14//! saying `approved_by = alice` is not — it is the server's word.
15//!
16//! # This module does NOT own the pull request
17//!
18//! [`crate::pulls`] does. The record there owns the number, the source and
19//! target refs, the state and the merge oid, under the control store's CAS —
20//! and it is the authority for all of them. This module owns exactly one thing:
21//! **what people said about that pull request**, as signatures.
22//!
23//! The split is one implementation applied to a question with two halves. "Which pull
24//! request is this, and has it merged" is a fact about an object, and an object
25//! needs one writer and a compare-and-swap. "Who approved commit `abc`" is a
26//! claim by a person, and a claim needs a signature, because the value of the
27//! answer is that a third party can check it without trusting this server. A
28//! row saying `approved_by = alice` is the server's word; an `sshsig` is
29//! alice's.
30//!
31//! So [`ForgeEvent::pull`] IS the pull request number. There is no separate
32//! numbering here and no `Opened`, `Merged` or `Closed` event: those are
33//! transitions of the record, and a second copy of them in this log would be a
34//! twin that could disagree with it.
35//!
36//! # Events, not state
37//!
38//! What is signed is what happened, and the current review state is folded from
39//! the events ([`fold`]) rather than stored. Two readers of the same log always
40//! agree, and a state nobody can point at an event for cannot exist.
41//!
42//! It also makes the audit question answerable, which a mutable row does not:
43//! *who approved this, and what exactly did they approve* — the approval names
44//! the commit it approved, so approving and then force-pushing produces an
45//! approval that no longer covers the tip, visibly. `merge_pull` refuses on
46//! exactly that, which is what turns the property into a gate rather than a
47//! display.
48//!
49//! # Where it sits in the chain
50//!
51//! Between links 2 and 3. A commit is signed by its author; the push that
52//! landed it is attested; and an **approval** is a third signature over the
53//! same commit by somebody else. A verifier that wants "was this reviewed"
54//! asks this log, and gets an answer it can check itself.
55
56/// The `sshsig` namespace a forge event is signed under.
57///
58/// Its own namespace, like every other signature in this repository: a review
59/// approval must never be presentable as a commit signature or a tree head, and
60/// `sshsig` binds the namespace into what is signed.
61pub const FORGE_NAMESPACE: &str = "gunnar-forge";
62
63/// What happened.
64///
65/// A closed set on purpose. An open one — a `kind: String` — would let a client
66/// invent an event nobody can fold, and a log that holds events its reader does
67/// not understand is a log whose state depends on who is reading it.
68#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69#[serde(tag = "kind", rename_all = "snake_case")]
70pub enum Event {
71 /// A comment on a pull request, or — when [`path`](Event::Commented::path)
72 /// is set — on ONE line of ONE file version.
73 ///
74 /// # The anchor follows the code, not the pull request
75 ///
76 /// An empty `path` is the original per-pull-request comment, and its signed
77 /// bytes are byte-for-byte what they were before the anchor existed. A
78 /// non-empty `path` anchors the comment to `(path, line, blob_oid)`, and the
79 /// blob is the load-bearing field: it names the exact file version the line
80 /// lived in. A rebase that does not touch the file leaves that blob's oid
81 /// unchanged, so the comment stays on its line; a rebase that rewrites the
82 /// file gives it a new blob, so the comment stops matching and the line it
83 /// was about is visibly gone. Anchoring to the pull request number instead
84 /// would carry the comment onto a rewritten file it was never made on.
85 Commented {
86 /// What was said.
87 body: String,
88 /// The file the comment is on, as a path within the tree. Empty for a
89 /// comment on the pull request as a whole.
90 #[serde(default)]
91 path: String,
92 /// The one-based line number within `blob_oid`.
93 #[serde(default)]
94 line: u32,
95 /// The full hex oid of the blob the commented line lived in.
96 #[serde(default)]
97 blob_oid: String,
98 },
99 /// **An approval of a specific commit.**
100 ///
101 /// The commit is part of what is signed, so an approval cannot silently
102 /// come to cover a different tip: force-push after a review and the
103 /// approval still names the commit that was reviewed, which is exactly the
104 /// fact an auditor needs and the one a mutable `approved: bool` destroys.
105 Approved {
106 /// The commit that was reviewed.
107 commit: String,
108 },
109 /// A withdrawal of an earlier approval.
110 Rejected {
111 /// The commit the withdrawn approval named.
112 commit: String,
113 /// Why.
114 reason: String,
115 },
116 /// **An issue opened.**
117 ///
118 /// The one place the forge grows a state machine of its own, because an
119 /// issue — unlike a pull request's review — has no [`crate::pulls`] record
120 /// that owns whether it is open. The issue IS the fold of its events, and
121 /// this is the event that says it exists. Its number is not signed: it is
122 /// [`fold_issues`]'s to assign, so that two readers of the same log agree on
123 /// it the way they agree on everything else here.
124 ///
125 /// That the OPENING is signed at all is the property GitHub, GitLab and
126 /// Gitea have no equivalent for: who filed this is the actor's own `sshsig`.
127 IssueOpened {
128 /// A one-line summary.
129 title: String,
130 /// The body, as filed.
131 body: String,
132 },
133 /// A comment on an issue.
134 ///
135 /// The issue number is [`ForgeEvent::pull`] — the subject the event is
136 /// about — so a comment is bound by the signature to its thread.
137 IssueCommented {
138 /// What was said.
139 body: String,
140 },
141 /// **An issue closed**, with the reason it was.
142 ///
143 /// Appended, never deleted: what the fold shows is that the issue is shut,
144 /// and the close — the reason with it — stays readable, which is the fact an
145 /// auditor wants.
146 IssueClosed {
147 /// Why it was closed.
148 reason: String,
149 },
150}
151
152impl Event {
153 fn tag(&self) -> &'static str {
154 match self {
155 Event::Commented { .. } => "commented",
156 Event::Approved { .. } => "approved",
157 Event::Rejected { .. } => "rejected",
158 Event::IssueOpened { .. } => "issue_opened",
159 Event::IssueCommented { .. } => "issue_commented",
160 Event::IssueClosed { .. } => "issue_closed",
161 }
162 }
163}
164
165/// One event on one pull request, before it is signed.
166#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct ForgeEvent {
169 /// `<namespace>/<repo>` this belongs to.
170 pub repo: String,
171 /// **Which pull request** — [`crate::pulls::PullRecord::number`].
172 ///
173 /// Assigned by `PullRequests.Create`, not here: the number is inside the
174 /// signature, so it has to be known before an event is signed, and a second
175 /// numbering authority is exactly the twin this design avoids.
176 pub pull: u64,
177 /// The ACL principal that did it.
178 pub actor: String,
179 /// When.
180 pub at_unix_ms: i64,
181 /// What.
182 pub event: Event,
183}
184
185impl ForgeEvent {
186 /// The exact bytes that are signed.
187 ///
188 /// Line-oriented and fixed-order, for the same reason a tree head and a
189 /// build record are: JSON has no canonical form, so signing a serialisation
190 /// makes the signature depend on the serialiser. The body is length-prefixed
191 /// rather than escaped, so a comment containing a newline cannot be made to
192 /// look like two fields.
193 pub fn signing_bytes(&self) -> Vec<u8> {
194 let mut out = String::new();
195 out.push_str("gunnar-forge-v1\n");
196 out.push_str(&format!("repo {}\n", self.repo));
197 out.push_str(&format!("pull {}\n", self.pull));
198 out.push_str(&format!("actor {}\n", self.actor));
199 out.push_str(&format!("at {}\n", self.at_unix_ms));
200 out.push_str(&format!("kind {}\n", self.event.tag()));
201 let mut field = |name: &str, value: &str| {
202 out.push_str(&format!("{name} {} {value}\n", value.len()));
203 };
204 match &self.event {
205 Event::Commented {
206 body,
207 path,
208 line,
209 blob_oid,
210 } => {
211 field("body", body);
212 // The anchor is bound only when it exists, so a per-pull comment
213 // signs exactly the bytes it did before inline comments were a
214 // thing. A comment with a path binds all three fields, each
215 // length-prefixed like `body`, so none can be moved under the
216 // signature and text in one cannot be read as another.
217 if !path.is_empty() {
218 field("path", path);
219 field("line", &line.to_string());
220 field("blob", blob_oid);
221 }
222 }
223 Event::Approved { commit } => field("commit", commit),
224 Event::Rejected { commit, reason } => {
225 field("commit", commit);
226 field("reason", reason);
227 }
228 Event::IssueOpened { title, body } => {
229 field("title", title);
230 field("body", body);
231 }
232 Event::IssueCommented { body } => field("body", body),
233 Event::IssueClosed { reason } => field("reason", reason),
234 }
235 out.into_bytes()
236 }
237}
238
239/// A [`ForgeEvent`] with the signature that makes it evidence.
240#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
241#[serde(deny_unknown_fields)]
242pub struct SignedEvent {
243 /// The event the signature is over.
244 #[serde(flatten)]
245 pub event: ForgeEvent,
246 /// `SHA256:…` of the actor's key.
247 pub key_fingerprint: String,
248 /// The `sshsig`, PEM-armoured.
249 pub signature: String,
250}
251
252/// Why a forge event did not verify.
253///
254/// The one shared [`crate::sshsig::EventProblem`], re-exported here so
255/// `forge::EventProblem` still names it: the forge log and the membership log
256/// verify through one function and so they report a refusal in one vocabulary
257/// (one implementation).
258pub use crate::sshsig::EventProblem;
259
260/// Sign a forge event with the actor's own key.
261///
262/// Through the one signer every signed log in this crate routes through
263/// ([`crate::sshsig::sign`]): the namespace is bound into the signature there,
264/// so a forge approval can never be presented under another log's namespace.
265pub fn sign_event(
266 key: &ssh_key::PrivateKey,
267 event: ForgeEvent,
268) -> Result<SignedEvent, ssh_key::Error> {
269 let (key_fingerprint, signature) =
270 crate::sshsig::sign(key, FORGE_NAMESPACE, &event.signing_bytes())?;
271 Ok(SignedEvent {
272 key_fingerprint,
273 signature,
274 event,
275 })
276}
277
278/// Verify a signed forge event against a public key.
279pub fn verify_event(signed: &SignedEvent, key: &ssh_key::PublicKey) -> Result<(), EventProblem> {
280 crate::sshsig::verify(
281 key,
282 FORGE_NAMESPACE,
283 &signed.event.signing_bytes(),
284 &signed.key_fingerprint,
285 &signed.signature,
286 )
287}
288
289/// **The review on one pull request**, right now.
290///
291/// Folded, never stored. This is deliberately NOT the pull request: the title,
292/// the refs, whether it is open and what it merged at all live in
293/// [`crate::pulls::PullRecord`], which is their one authority. What is here is
294/// only what cannot be a stored row without losing its value — who approves
295/// what, and how many people have spoken.
296#[derive(Debug, Clone, PartialEq, Eq, Default)]
297pub struct Review {
298 /// The pull request this review belongs to.
299 pub pull: u64,
300 /// Who currently approves which commit.
301 ///
302 /// A map and not a count: *whose* approval, and **of what**, are the two
303 /// things an auditor asks, and a count answers neither. A later `Rejected`
304 /// by the same actor removes their entry; a new `Approved` replaces it.
305 pub approvals: std::collections::BTreeMap<String, String>,
306 /// How many comments have been made.
307 pub comments: usize,
308}
309
310impl Review {
311 /// Who approves `commit` right now.
312 ///
313 /// The commit matters: an approval of an earlier tip is **not** an approval
314 /// of this one, and a forge that treated it as one would let a force-push
315 /// inherit a review.
316 pub fn approvers_of(&self, commit: &str) -> Vec<&str> {
317 self.approvals
318 .iter()
319 .filter(|(_, approved)| approved.as_str() == commit)
320 .map(|(actor, _)| actor.as_str())
321 .collect()
322 }
323}
324
325/// Fold one pull request's review events, in order.
326///
327/// Events for other pull requests are ignored, so a caller may hand in a whole
328/// repository's log.
329///
330/// Always returns a [`Review`], even an empty one: whether the pull request
331/// exists is [`crate::pulls`]'s question, and answering `None` here would be
332/// this module having an opinion about that. "Nobody has said anything yet" and
333/// "there is no such pull request" are different facts with different owners.
334pub fn fold(pull: u64, events: &[ForgeEvent]) -> Review {
335 let mut state = Review {
336 pull,
337 ..Review::default()
338 };
339 for event in events.iter().filter(|e| e.pull == pull) {
340 match &event.event {
341 Event::Commented { .. } => state.comments += 1,
342 Event::Approved { commit } => {
343 state.approvals.insert(event.actor.clone(), commit.clone());
344 }
345 Event::Rejected { .. } => {
346 state.approvals.remove(&event.actor);
347 }
348 // Issue events are a different subject with a different fold
349 // ([`fold_issues`]). A review of pull request #7 and issue #7 share
350 // a number but never a state — the kind keeps them apart, so an
351 // issue comment can never inflate a pull request's comment count.
352 Event::IssueOpened { .. } | Event::IssueCommented { .. } | Event::IssueClosed { .. } => {
353 }
354 }
355 }
356 state
357}
358
359/// **One issue**, folded from its events.
360///
361/// Unlike [`Review`], this owns its whole state — the title, the body, whether
362/// it is open — because unlike a review there is no [`crate::pulls`] record
363/// behind it. An issue is nothing but the fold of its signed events, so the
364/// fold is the only authority for what it says.
365#[derive(Debug, Clone, PartialEq, Eq, Default)]
366pub struct Issue {
367 /// The number the fold assigned: the Nth [`Event::IssueOpened`] in the
368 /// repository is issue N.
369 pub number: u64,
370 /// The one-line summary the opener signed.
371 pub title: String,
372 /// The body the opener signed.
373 pub body: String,
374 /// The ACL principal that opened it — the opening event's actor.
375 pub author: String,
376 /// Whether it is open right now. An [`Event::IssueClosed`] shuts it.
377 pub open: bool,
378 /// How many comments have been made.
379 pub comments: usize,
380 /// When it was opened.
381 pub opened_at_unix_ms: i64,
382}
383
384/// **Fold every issue in a repository**, in the order it was opened.
385///
386/// The caller hands in one repository's events, in log order. Numbering is
387/// positional and therefore reproducible: the first [`Event::IssueOpened`] is
388/// issue 1, the second is issue 2, and a comment or close names the number it
389/// is about in [`ForgeEvent::pull`]. An event naming a number nothing opened is
390/// dropped — it is evidence about nothing — the same way [`fold`] drops another
391/// pull request's events.
392///
393/// Review events (`Commented`/`Approved`/`Rejected`) are ignored: they are a
394/// different subject, so a whole repository's forge log may be handed in.
395pub fn fold_issues(events: &[ForgeEvent]) -> Vec<Issue> {
396 let mut issues: Vec<Issue> = Vec::new();
397 for event in events {
398 match &event.event {
399 Event::IssueOpened { title, body } => {
400 let number = issues.len() as u64 + 1;
401 issues.push(Issue {
402 number,
403 title: title.clone(),
404 body: body.clone(),
405 author: event.actor.clone(),
406 open: true,
407 comments: 0,
408 opened_at_unix_ms: event.at_unix_ms,
409 });
410 }
411 Event::IssueCommented { .. } => {
412 if let Some(issue) = issues.iter_mut().find(|i| i.number == event.pull) {
413 issue.comments += 1;
414 }
415 }
416 Event::IssueClosed { .. } => {
417 if let Some(issue) = issues.iter_mut().find(|i| i.number == event.pull) {
418 issue.open = false;
419 }
420 }
421 Event::Commented { .. } | Event::Approved { .. } | Event::Rejected { .. } => {}
422 }
423 }
424 issues
425}
426
427/// **One issue**, folded, or `None` if nothing opened that number.
428///
429/// `None` here is a real answer and not [`fold`]'s deliberate always-`Some`: a
430/// review folds to an empty review because whether the pull request exists is
431/// [`crate::pulls`]'s question, but whether an issue exists is exactly THIS
432/// fold's question — there is no other owner to ask — so a number nobody opened
433/// is `None`.
434pub fn fold_issue(number: u64, events: &[ForgeEvent]) -> Option<Issue> {
435 fold_issues(events).into_iter().find(|i| i.number == number)
436}
437
438/// One inline comment, extracted from the review log and shaped for rendering
439/// on a diff.
440///
441/// Deliberately carries its own `blob_oid`: the caller matches it against a
442/// file's blob in the diff it is showing, and that match — not the pull
443/// request's head commit — is what puts the comment on the right line.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct InlineComment {
446 /// The ACL principal who wrote it.
447 pub actor: String,
448 /// The file it is on.
449 pub path: String,
450 /// The one-based line within `blob_oid`.
451 pub line: u32,
452 /// The blob the commented line lived in.
453 pub blob_oid: String,
454 /// What was said.
455 pub body: String,
456 /// When.
457 pub at_unix_ms: i64,
458}
459
460/// Every INLINE comment on one pull request, in event order.
461///
462/// A `Commented` with an empty `path` is a comment on the pull request as a
463/// whole — it is counted by [`fold`] as a comment and does not appear here,
464/// because it has no line to sit on.
465pub fn inline_comments(pull: u64, events: &[ForgeEvent]) -> Vec<InlineComment> {
466 events
467 .iter()
468 .filter(|e| e.pull == pull)
469 .filter_map(|e| match &e.event {
470 Event::Commented {
471 body,
472 path,
473 line,
474 blob_oid,
475 } if !path.is_empty() => Some(InlineComment {
476 actor: e.actor.clone(),
477 path: path.clone(),
478 line: *line,
479 blob_oid: blob_oid.clone(),
480 body: body.clone(),
481 at_unix_ms: e.at_unix_ms,
482 }),
483 _ => None,
484 })
485 .collect()
486}
487
488/// The inline comments that belong on `path` at blob `blob_oid`.
489///
490/// **Matched by the blob, never by the pull request's head.** The whole point
491/// of anchoring a comment to a blob is that the blob is the exact bytes the
492/// line was written against: give this the blob a file has in the diff being
493/// rendered and it returns only the comments that were made on THAT version of
494/// the file. A rebase that left the file alone hands back the same blob and the
495/// comments still attach; a rebase that rewrote the file hands back a different
496/// blob and they do not, because the line they named is gone.
497pub fn comments_on_blob<'a>(
498 comments: &'a [InlineComment],
499 path: &str,
500 blob_oid: &str,
501) -> Vec<&'a InlineComment> {
502 comments
503 .iter()
504 .filter(|c| c.path == path && c.blob_oid == blob_oid)
505 .collect()
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 fn event(pull: u64, actor: &str, at: i64, event: Event) -> ForgeEvent {
513 ForgeEvent {
514 repo: "team/repo".into(),
515 pull,
516 actor: actor.into(),
517 at_unix_ms: at,
518 event,
519 }
520 }
521
522 fn key() -> ssh_key::PrivateKey {
523 ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
524 }
525
526 /// A comment on the pull request as a whole — no inline anchor.
527 fn plain(body: &str) -> Event {
528 Event::Commented {
529 body: body.into(),
530 path: String::new(),
531 line: 0,
532 blob_oid: String::new(),
533 }
534 }
535
536 /// **An approval is evidence**: signed by the reviewer, over the commit
537 /// they reviewed, and every field bound.
538 #[test]
539 fn an_approval_verifies_and_no_field_can_be_moved_under_it() {
540 let alice = key();
541 let mallory = key();
542 let signed = sign_event(
543 &alice,
544 event(
545 7,
546 "alice",
547 1_700_000_000_000,
548 Event::Approved {
549 commit: "a".repeat(40),
550 },
551 ),
552 )
553 .expect("sign");
554 assert_eq!(verify_event(&signed, alice.public_key()), Ok(()));
555 assert!(matches!(
556 verify_event(&signed, mallory.public_key()),
557 Err(EventProblem::WrongKey { .. })
558 ));
559
560 // The commit above all: an approval that can be re-pointed is an
561 // approval a force-push inherits.
562 for mutate in [
563 |e: &mut ForgeEvent| {
564 e.event = Event::Approved {
565 commit: "b".repeat(40),
566 }
567 },
568 |e: &mut ForgeEvent| e.actor = "mallory".into(),
569 |e: &mut ForgeEvent| e.pull = 8,
570 |e: &mut ForgeEvent| e.repo = "other/repo".into(),
571 |e: &mut ForgeEvent| e.at_unix_ms += 1,
572 ] {
573 let mut tampered = signed.clone();
574 mutate(&mut tampered.event);
575 assert_eq!(
576 verify_event(&tampered, alice.public_key()),
577 Err(EventProblem::NotVerified),
578 "a field moved under the signature: {:?}",
579 tampered.event
580 );
581 }
582 }
583
584 /// A signature made under another namespace is refused — so a **commit**
585 /// signature can never be presented as an approval.
586 #[test]
587 fn a_commit_signature_cannot_be_presented_as_an_approval() {
588 let alice = key();
589 let event = event(
590 1,
591 "alice",
592 1,
593 Event::Approved {
594 commit: "c".repeat(40),
595 },
596 );
597 let elsewhere = alice
598 .sign("git", ssh_key::HashAlg::Sha512, &event.signing_bytes())
599 .expect("sign");
600 let smuggled = SignedEvent {
601 key_fingerprint: alice
602 .public_key()
603 .fingerprint(ssh_key::HashAlg::Sha256)
604 .to_string(),
605 signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("armour"),
606 event,
607 };
608 assert!(matches!(
609 verify_event(&smuggled, alice.public_key()),
610 Err(EventProblem::Malformed(_))
611 ));
612 }
613
614 /// **A comment with a newline cannot forge a field.** The body is
615 /// length-prefixed, so text that looks like `\nactor mallory` is text.
616 #[test]
617 fn a_body_cannot_smuggle_a_field_past_the_signature() {
618 let honest = event(1, "alice", 1, plain("looks fine"));
619 let sneaky = event(1, "alice", 1, plain("looks fine\nactor mallory"));
620 assert_ne!(honest.signing_bytes(), sneaky.signing_bytes());
621 // And the length prefix is what does it: the two bodies differ in
622 // length, so no padding can make them agree.
623 let text = String::from_utf8(sneaky.signing_bytes()).expect("utf-8");
624 assert!(
625 text.contains("body 24 looks fine\nactor mallory"),
626 "the body must be length-prefixed: {text}"
627 );
628 }
629
630 /// The fold is the review, and it answers **who approved what**.
631 #[test]
632 fn the_fold_tracks_approvals_by_actor_and_by_commit() {
633 let old = "1".repeat(40);
634 let new = "2".repeat(40);
635 let events = vec![
636 event(1, "bob", 2, plain("ok")),
637 event(
638 1,
639 "bob",
640 3,
641 Event::Approved {
642 commit: old.clone(),
643 },
644 ),
645 event(
646 1,
647 "carol",
648 4,
649 Event::Approved {
650 commit: old.clone(),
651 },
652 ),
653 // Another pull request's events must not leak into this fold.
654 event(
655 2,
656 "dave",
657 5,
658 Event::Approved {
659 commit: new.clone(),
660 },
661 ),
662 ];
663
664 let review = fold(1, &events);
665 assert_eq!(review.comments, 1);
666 let mut approvers = review.approvers_of(&old);
667 approvers.sort();
668 assert_eq!(approvers, vec!["bob", "carol"]);
669
670 // **A new commit is not approved just because the old one was.**
671 assert!(
672 review.approvers_of(&new).is_empty(),
673 "an approval of an earlier commit was counted for a later one, so a force-push \
674 inherits a review"
675 );
676 }
677
678 /// A withdrawal removes that actor's approval and nobody else's.
679 #[test]
680 fn a_withdrawal_removes_one_approval_and_leaves_the_others() {
681 let commit = "3".repeat(40);
682 let events = vec![
683 event(
684 1,
685 "bob",
686 2,
687 Event::Approved {
688 commit: commit.clone(),
689 },
690 ),
691 event(
692 1,
693 "carol",
694 3,
695 Event::Approved {
696 commit: commit.clone(),
697 },
698 ),
699 event(
700 1,
701 "bob",
702 4,
703 Event::Rejected {
704 commit: commit.clone(),
705 reason: "second thoughts".into(),
706 },
707 ),
708 ];
709 assert_eq!(fold(1, &events).approvers_of(&commit), vec!["carol"]);
710 }
711
712 /// **A pull request nobody has reviewed folds to an empty review, not to
713 /// nothing.**
714 ///
715 /// Whether the pull request exists is `crate::pulls`'s question. Answering
716 /// `None` here would be this module having an opinion about a record it
717 /// does not own — and "nobody has approved it" is the answer a merge gate
718 /// needs, which is not the same as "there is no such pull request".
719 #[test]
720 fn an_unreviewed_pull_folds_to_an_empty_review() {
721 let none = fold(9, &[]);
722 assert_eq!(none.pull, 9);
723 assert!(none.approvals.is_empty());
724 assert_eq!(none.comments, 0);
725 assert!(none.approvers_of(&"4".repeat(40)).is_empty());
726 }
727
728 /// **An issue opened is evidence**: signed by its author, over the exact
729 /// title and body, every field bound. The mutation loop is what makes this a
730 /// guard and not a round-trip — break any field and the signature must fail.
731 #[test]
732 fn an_issue_open_verifies_and_no_field_can_be_moved_under_it() {
733 let alice = key();
734 let mallory = key();
735 let signed = sign_event(
736 &alice,
737 event(
738 // `pull` is 0 for an opening: the number does not exist yet.
739 0,
740 "alice",
741 1_700_000_000_000,
742 Event::IssueOpened {
743 title: "the installer wedges on a 0600 kernel".into(),
744 body: "forge says no kernel to bundle".into(),
745 },
746 ),
747 )
748 .expect("sign");
749 assert_eq!(verify_event(&signed, alice.public_key()), Ok(()));
750 // A different key is not this author.
751 assert!(matches!(
752 verify_event(&signed, mallory.public_key()),
753 Err(EventProblem::WrongKey { .. })
754 ));
755
756 for mutate in [
757 |e: &mut ForgeEvent| {
758 e.event = Event::IssueOpened {
759 title: "something else entirely".into(),
760 body: "forge says no kernel to bundle".into(),
761 }
762 },
763 |e: &mut ForgeEvent| {
764 e.event = Event::IssueOpened {
765 title: "the installer wedges on a 0600 kernel".into(),
766 body: "a body nobody wrote".into(),
767 }
768 },
769 |e: &mut ForgeEvent| e.actor = "mallory".into(),
770 |e: &mut ForgeEvent| e.repo = "other/repo".into(),
771 ] {
772 let mut tampered = signed.clone();
773 mutate(&mut tampered.event);
774 assert_eq!(
775 verify_event(&tampered, alice.public_key()),
776 Err(EventProblem::NotVerified),
777 "a field moved under the signature: {:?}",
778 tampered.event
779 );
780 }
781 }
782
783 /// **The fold is the issue**: numbering is positional, a close shuts it, a
784 /// comment on a number nobody opened is dropped, and one issue's comment
785 /// does not land on another.
786 #[test]
787 fn issues_fold_by_position_and_a_close_shuts_the_right_one() {
788 let events = vec![
789 event(
790 0,
791 "alice",
792 10,
793 Event::IssueOpened {
794 title: "first".into(),
795 body: "one".into(),
796 },
797 ),
798 event(
799 0,
800 "bob",
801 11,
802 Event::IssueOpened {
803 title: "second".into(),
804 body: "two".into(),
805 },
806 ),
807 event(1, "carol", 12, Event::IssueCommented { body: "on #1".into() }),
808 event(2, "carol", 13, Event::IssueCommented { body: "on #2".into() }),
809 event(2, "dave", 14, Event::IssueCommented { body: "also #2".into() }),
810 // A comment on an issue nobody opened is evidence about nothing.
811 event(9, "eve", 15, Event::IssueCommented { body: "ghost".into() }),
812 event(
813 1,
814 "alice",
815 16,
816 Event::IssueClosed {
817 reason: "fixed".into(),
818 },
819 ),
820 ];
821
822 let issues = fold_issues(&events);
823 assert_eq!(issues.len(), 2, "two openings, two issues");
824
825 let one = fold_issue(1, &events).expect("issue 1");
826 assert_eq!(one.number, 1);
827 assert_eq!(one.title, "first");
828 assert_eq!(one.author, "alice");
829 assert_eq!(one.comments, 1, "only #1's own comment counts for #1");
830 assert!(!one.open, "an IssueClosed shuts it");
831
832 let two = fold_issue(2, &events).expect("issue 2");
833 assert_eq!(two.comments, 2);
834 assert!(two.open, "closing #1 must not touch #2");
835
836 // A number nobody opened is None — this fold IS the authority for
837 // whether the issue exists, so it says so.
838 assert!(fold_issue(9, &events).is_none());
839 }
840
841 /// **A review event never leaks into an issue, and the reverse.** They share
842 /// the subject number but not the kind, and the kind is what the fold keys
843 /// on — so a pull request #1 comment cannot inflate issue #1.
844 #[test]
845 fn a_review_comment_is_not_an_issue_comment() {
846 let events = vec![
847 event(
848 0,
849 "alice",
850 1,
851 Event::IssueOpened {
852 title: "an issue".into(),
853 body: "b".into(),
854 },
855 ),
856 // Same number as the issue, but a REVIEW comment.
857 event(1, "bob", 2, plain("on the PR")),
858 ];
859 assert_eq!(fold_issue(1, &events).expect("issue 1").comments, 0);
860 // And the review fold sees its own comment but NOT the issue opening:
861 // one review comment, and the IssueOpened contributes nothing to it.
862 assert_eq!(fold(1, &events).comments, 1);
863 }
864
865 /// **An inline comment follows the blob, not the pull request.**
866 ///
867 /// The comment is made on `src/lib.rs` at line 42, against the blob that
868 /// file had when it was reviewed. An unrelated commit later in the pull
869 /// request changes the head but leaves `src/lib.rs` — and therefore its blob
870 /// oid — alone, so the comment is still on line 42. Rewrite the file and its
871 /// blob changes, so the comment no longer attaches: the line it named is
872 /// gone. A matcher keyed on the pull number would have kept it on the
873 /// rewritten file, which is the bug this anchoring exists to avoid.
874 #[test]
875 fn an_inline_comment_follows_the_blob_not_the_pull() {
876 let reviewed = "a".repeat(40);
877 let rewritten = "b".repeat(40);
878 let events = vec![
879 event(
880 1,
881 "alice",
882 10,
883 Event::Commented {
884 body: "off-by-one here".into(),
885 path: "src/lib.rs".into(),
886 line: 42,
887 blob_oid: reviewed.clone(),
888 },
889 ),
890 // A per-pull comment must not be mistaken for an inline one: it has
891 // no line to sit on.
892 event(1, "bob", 11, plain("LGTM overall")),
893 // Another pull request's inline comment must not leak in.
894 event(
895 2,
896 "carol",
897 12,
898 Event::Commented {
899 body: "different pr".into(),
900 path: "src/lib.rs".into(),
901 line: 42,
902 blob_oid: reviewed.clone(),
903 },
904 ),
905 ];
906
907 let inline = inline_comments(1, &events);
908 assert_eq!(
909 inline.len(),
910 1,
911 "the per-pull comment and the other pull's comment must not appear as this pull's \
912 inline comments"
913 );
914
915 // The file was untouched by the unrelated commit: same blob, so the
916 // comment is still on its line, with its exact author and body.
917 let stays = comments_on_blob(&inline, "src/lib.rs", &reviewed);
918 assert_eq!(stays.len(), 1);
919 assert_eq!(stays[0].line, 42);
920 assert_eq!(stays[0].actor, "alice");
921 assert_eq!(stays[0].body, "off-by-one here");
922
923 // The file was rewritten: a different blob oid. The comment does NOT
924 // attach — anchoring to the pull number would have wrongly shown it on
925 // a file version it was never made on.
926 assert!(
927 comments_on_blob(&inline, "src/lib.rs", &rewritten).is_empty(),
928 "the comment attached to a blob it was never made on: it is keyed on the pull, not \
929 the code"
930 );
931 }
932
933 /// **Every field of the inline anchor is bound by the signature**, and a
934 /// per-pull comment's bytes are unchanged from before the anchor existed.
935 #[test]
936 fn an_inline_anchor_is_bound_and_a_plain_comment_is_unchanged() {
937 let base = event(
938 1,
939 "alice",
940 1,
941 Event::Commented {
942 body: "here".into(),
943 path: "a.rs".into(),
944 line: 5,
945 blob_oid: "a".repeat(40),
946 },
947 );
948 for mutate in [
949 |e: &mut ForgeEvent| {
950 if let Event::Commented { path, .. } = &mut e.event {
951 *path = "b.rs".into();
952 }
953 },
954 |e: &mut ForgeEvent| {
955 if let Event::Commented { line, .. } = &mut e.event {
956 *line = 6;
957 }
958 },
959 |e: &mut ForgeEvent| {
960 if let Event::Commented { blob_oid, .. } = &mut e.event {
961 *blob_oid = "b".repeat(40);
962 }
963 },
964 ] {
965 let mut moved = base.clone();
966 mutate(&mut moved);
967 assert_ne!(
968 base.signing_bytes(),
969 moved.signing_bytes(),
970 "an inline anchor field moved without changing what is signed: {:?}",
971 moved.event
972 );
973 }
974
975 // A per-pull comment signs exactly what it did before inline comments
976 // were a thing: `body` and nothing after it.
977 let text = String::from_utf8(event(1, "alice", 1, plain("hi")).signing_bytes())
978 .expect("utf-8");
979 assert!(
980 text.ends_with("kind commented\nbody 2 hi\n"),
981 "a per-pull comment's signed bytes changed: {text}"
982 );
983 }
984}