crates/gunnar-core/src/forge.rs
//! **The review half of a pull request: its evidence** (roadmap phase 1).
//!
//! Comments and approvals, as **signed append-only events**.
//!
//! The roadmap's claim about this phase is not "gunnar grows issues". It is:
//!
//! > Every forge event … is an sshsig-signed row under the principal's own key.
//! > An approval therefore carries the same evidential weight as a commit — and
//! > becomes a link in the chain: the artifact can show who approved the change.
//!
//! That is what this module is. An approval signed by the reviewer's own key is
//! evidence in exactly the sense a signed commit is: it can be checked by
//! anybody, later, without trusting the server that stored it. A row in a table
//! saying `approved_by = alice` is not — it is the server's word.
//!
//! # This module does NOT own the pull request
//!
//! [`crate::pulls`] does. The record there owns the number, the source and
//! target refs, the state and the merge oid, under the control store's CAS —
//! and it is the authority for all of them. This module owns exactly one thing:
//! **what people said about that pull request**, as signatures.
//!
//! The split is one implementation applied to a question with two halves. "Which pull
//! request is this, and has it merged" is a fact about an object, and an object
//! needs one writer and a compare-and-swap. "Who approved commit `abc`" is a
//! claim by a person, and a claim needs a signature, because the value of the
//! answer is that a third party can check it without trusting this server. A
//! row saying `approved_by = alice` is the server's word; an `sshsig` is
//! alice's.
//!
//! So [`ForgeEvent::pull`] IS the pull request number. There is no separate
//! numbering here and no `Opened`, `Merged` or `Closed` event: those are
//! transitions of the record, and a second copy of them in this log would be a
//! twin that could disagree with it.
//!
//! # Events, not state
//!
//! What is signed is what happened, and the current review state is folded from
//! the events ([`fold`]) rather than stored. Two readers of the same log always
//! agree, and a state nobody can point at an event for cannot exist.
//!
//! It also makes the audit question answerable, which a mutable row does not:
//! *who approved this, and what exactly did they approve* — the approval names
//! the commit it approved, so approving and then force-pushing produces an
//! approval that no longer covers the tip, visibly. `merge_pull` refuses on
//! exactly that, which is what turns the property into a gate rather than a
//! display.
//!
//! # Where it sits in the chain
//!
//! Between links 2 and 3. A commit is signed by its author; the push that
//! landed it is attested; and an **approval** is a third signature over the
//! same commit by somebody else. A verifier that wants "was this reviewed"
//! asks this log, and gets an answer it can check itself.
/// The `sshsig` namespace a forge event is signed under.
///
/// Its own namespace, like every other signature in this repository: a review
/// approval must never be presentable as a commit signature or a tree head, and
/// `sshsig` binds the namespace into what is signed.
pub const FORGE_NAMESPACE: &str = "gunnar-forge";
/// What happened.
///
/// A closed set on purpose. An open one — a `kind: String` — would let a client
/// invent an event nobody can fold, and a log that holds events its reader does
/// not understand is a log whose state depends on who is reading it.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Event {
/// A comment on a pull request, or — when [`path`](Event::Commented::path)
/// is set — on ONE line of ONE file version.
///
/// # The anchor follows the code, not the pull request
///
/// An empty `path` is the original per-pull-request comment, and its signed
/// bytes are byte-for-byte what they were before the anchor existed. A
/// non-empty `path` anchors the comment to `(path, line, blob_oid)`, and the
/// blob is the load-bearing field: it names the exact file version the line
/// lived in. A rebase that does not touch the file leaves that blob's oid
/// unchanged, so the comment stays on its line; a rebase that rewrites the
/// file gives it a new blob, so the comment stops matching and the line it
/// was about is visibly gone. Anchoring to the pull request number instead
/// would carry the comment onto a rewritten file it was never made on.
Commented {
/// What was said.
body: String,
/// The file the comment is on, as a path within the tree. Empty for a
/// comment on the pull request as a whole.
#[serde(default)]
path: String,
/// The one-based line number within `blob_oid`.
#[serde(default)]
line: u32,
/// The full hex oid of the blob the commented line lived in.
#[serde(default)]
blob_oid: String,
},
/// **An approval of a specific commit.**
///
/// The commit is part of what is signed, so an approval cannot silently
/// come to cover a different tip: force-push after a review and the
/// approval still names the commit that was reviewed, which is exactly the
/// fact an auditor needs and the one a mutable `approved: bool` destroys.
Approved {
/// The commit that was reviewed.
commit: String,
},
/// A withdrawal of an earlier approval.
Rejected {
/// The commit the withdrawn approval named.
commit: String,
/// Why.
reason: String,
},
/// **An issue opened.**
///
/// The one place the forge grows a state machine of its own, because an
/// issue — unlike a pull request's review — has no [`crate::pulls`] record
/// that owns whether it is open. The issue IS the fold of its events, and
/// this is the event that says it exists. Its number is not signed: it is
/// [`fold_issues`]'s to assign, so that two readers of the same log agree on
/// it the way they agree on everything else here.
///
/// That the OPENING is signed at all is the property GitHub, GitLab and
/// Gitea have no equivalent for: who filed this is the actor's own `sshsig`.
IssueOpened {
/// A one-line summary.
title: String,
/// The body, as filed.
body: String,
},
/// A comment on an issue.
///
/// The issue number is [`ForgeEvent::pull`] — the subject the event is
/// about — so a comment is bound by the signature to its thread.
IssueCommented {
/// What was said.
body: String,
},
/// **An issue closed**, with the reason it was.
///
/// Appended, never deleted: what the fold shows is that the issue is shut,
/// and the close — the reason with it — stays readable, which is the fact an
/// auditor wants.
IssueClosed {
/// Why it was closed.
reason: String,
},
}
impl Event {
fn tag(&self) -> &'static str {
match self {
Event::Commented { .. } => "commented",
Event::Approved { .. } => "approved",
Event::Rejected { .. } => "rejected",
Event::IssueOpened { .. } => "issue_opened",
Event::IssueCommented { .. } => "issue_commented",
Event::IssueClosed { .. } => "issue_closed",
}
}
}
/// One event on one pull request, before it is signed.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ForgeEvent {
/// `<namespace>/<repo>` this belongs to.
pub repo: String,
/// **Which pull request** — [`crate::pulls::PullRecord::number`].
///
/// Assigned by `PullRequests.Create`, not here: the number is inside the
/// signature, so it has to be known before an event is signed, and a second
/// numbering authority is exactly the twin this design avoids.
pub pull: u64,
/// The ACL principal that did it.
pub actor: String,
/// When.
pub at_unix_ms: i64,
/// What.
pub event: Event,
}
impl ForgeEvent {
/// The exact bytes that are signed.
///
/// Line-oriented and fixed-order, for the same reason a tree head and a
/// build record are: JSON has no canonical form, so signing a serialisation
/// makes the signature depend on the serialiser. The body is length-prefixed
/// rather than escaped, so a comment containing a newline cannot be made to
/// look like two fields.
pub fn signing_bytes(&self) -> Vec<u8> {
let mut out = String::new();
out.push_str("gunnar-forge-v1\n");
out.push_str(&format!("repo {}\n", self.repo));
out.push_str(&format!("pull {}\n", self.pull));
out.push_str(&format!("actor {}\n", self.actor));
out.push_str(&format!("at {}\n", self.at_unix_ms));
out.push_str(&format!("kind {}\n", self.event.tag()));
let mut field = |name: &str, value: &str| {
out.push_str(&format!("{name} {} {value}\n", value.len()));
};
match &self.event {
Event::Commented {
body,
path,
line,
blob_oid,
} => {
field("body", body);
// The anchor is bound only when it exists, so a per-pull comment
// signs exactly the bytes it did before inline comments were a
// thing. A comment with a path binds all three fields, each
// length-prefixed like `body`, so none can be moved under the
// signature and text in one cannot be read as another.
if !path.is_empty() {
field("path", path);
field("line", &line.to_string());
field("blob", blob_oid);
}
}
Event::Approved { commit } => field("commit", commit),
Event::Rejected { commit, reason } => {
field("commit", commit);
field("reason", reason);
}
Event::IssueOpened { title, body } => {
field("title", title);
field("body", body);
}
Event::IssueCommented { body } => field("body", body),
Event::IssueClosed { reason } => field("reason", reason),
}
out.into_bytes()
}
}
/// A [`ForgeEvent`] with the signature that makes it evidence.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedEvent {
/// The event the signature is over.
#[serde(flatten)]
pub event: ForgeEvent,
/// `SHA256:…` of the actor's key — or, on an [`attested`](SignedEvent::attested)
/// event, of gunnar's own forge-attestation key.
pub key_fingerprint: String,
/// The `sshsig`, PEM-armoured.
pub signature: String,
/// **Gunnar's attestation** (GHUNT-19), on an event a person made from a
/// session that stepped up, which holds no key of theirs to sign with:
/// gunnar verified that session's fresh touch of the person's own passkey and
/// signed the event with its own key, naming the actor. `None` on an event
/// the actor signed with their own key — byte for byte what it was before.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attested: Option<Attested>,
}
/// **What gunnar attests** about an event it signed for a person's session:
/// the session had stepped up (a fresh, user-verified touch of the person's own
/// passkey, within the identity core's window) at this time. Inside the
/// signature, so it cannot be moved onto another event or taken off this one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Attested {
/// When the session stepped up, Unix milliseconds.
pub session_stepped_up_at_unix_ms: i64,
}
impl Attested {
/// The words an operator reads next to an attested event.
pub fn words(&self) -> String {
format!(
"attested for session step-up at {}",
crate::flow::iso8601_utc(self.session_stepped_up_at_unix_ms)
)
}
}
/// The exact bytes a signed event's signature is over: the event's own bytes,
/// and gunnar's attestation line when there is one.
fn signed_bytes(event: &ForgeEvent, attested: Option<&Attested>) -> Vec<u8> {
let mut out = event.signing_bytes();
if let Some(attested) = attested {
out.extend_from_slice(
format!(
"attested session-step-up {}\n",
attested.session_stepped_up_at_unix_ms
)
.as_bytes(),
);
}
out
}
/// Why a forge event did not verify.
///
/// The one shared [`crate::sshsig::EventProblem`], re-exported here so
/// `forge::EventProblem` still names it: the forge log and the membership log
/// verify through one function and so they report a refusal in one vocabulary
/// (one implementation).
pub use crate::sshsig::EventProblem;
/// Sign a forge event with the actor's own key.
///
/// Through the one signer every signed log in this crate routes through
/// ([`crate::sshsig::sign`]): the namespace is bound into the signature there,
/// so a forge approval can never be presented under another log's namespace.
pub fn sign_event(
key: &ssh_key::PrivateKey,
event: ForgeEvent,
) -> Result<SignedEvent, ssh_key::Error> {
let (key_fingerprint, signature) =
crate::sshsig::sign(key, FORGE_NAMESPACE, &event.signing_bytes())?;
Ok(SignedEvent {
key_fingerprint,
signature,
event,
attested: None,
})
}
/// **Gunnar signs a person's event with its own key, and says why** (GHUNT-19):
/// `key` is gunnar's forge-attestation key, never a person's, and the
/// attestation is inside the signature.
pub fn attest_event(
key: &ssh_key::PrivateKey,
event: ForgeEvent,
attested: Attested,
) -> Result<SignedEvent, ssh_key::Error> {
let (key_fingerprint, signature) =
crate::sshsig::sign(key, FORGE_NAMESPACE, &signed_bytes(&event, Some(&attested)))?;
Ok(SignedEvent {
key_fingerprint,
signature,
event,
attested: Some(attested),
})
}
/// Verify a signed forge event against a public key (gunnar's attestation
/// included, when the event carries one).
pub fn verify_event(signed: &SignedEvent, key: &ssh_key::PublicKey) -> Result<(), EventProblem> {
crate::sshsig::verify(
key,
FORGE_NAMESPACE,
&signed_bytes(&signed.event, signed.attested.as_ref()),
&signed.key_fingerprint,
&signed.signature,
)
}
/// **The review on one pull request**, right now.
///
/// Folded, never stored. This is deliberately NOT the pull request: the title,
/// the refs, whether it is open and what it merged at all live in
/// [`crate::pulls::PullRecord`], which is their one authority. What is here is
/// only what cannot be a stored row without losing its value — who approves
/// what, and how many people have spoken.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Review {
/// The pull request this review belongs to.
pub pull: u64,
/// Who currently approves which commit.
///
/// A map and not a count: *whose* approval, and **of what**, are the two
/// things an auditor asks, and a count answers neither. A later `Rejected`
/// by the same actor removes their entry; a new `Approved` replaces it.
pub approvals: std::collections::BTreeMap<String, String>,
/// How many comments have been made.
pub comments: usize,
}
impl Review {
/// Who approves `commit` right now.
///
/// The commit matters: an approval of an earlier tip is **not** an approval
/// of this one, and a forge that treated it as one would let a force-push
/// inherit a review.
pub fn approvers_of(&self, commit: &str) -> Vec<&str> {
self.approvals
.iter()
.filter(|(_, approved)| approved.as_str() == commit)
.map(|(actor, _)| actor.as_str())
.collect()
}
}
/// Fold one pull request's review events, in order.
///
/// Events for other pull requests are ignored, so a caller may hand in a whole
/// repository's log.
///
/// Always returns a [`Review`], even an empty one: whether the pull request
/// exists is [`crate::pulls`]'s question, and answering `None` here would be
/// this module having an opinion about that. "Nobody has said anything yet" and
/// "there is no such pull request" are different facts with different owners.
pub fn fold(pull: u64, events: &[ForgeEvent]) -> Review {
let mut state = Review {
pull,
..Review::default()
};
for event in events.iter().filter(|e| e.pull == pull) {
match &event.event {
Event::Commented { .. } => state.comments += 1,
Event::Approved { commit } => {
state.approvals.insert(event.actor.clone(), commit.clone());
}
Event::Rejected { .. } => {
state.approvals.remove(&event.actor);
}
// Issue events are a different subject with a different fold
// ([`fold_issues`]). A review of pull request #7 and issue #7 share
// a number but never a state — the kind keeps them apart, so an
// issue comment can never inflate a pull request's comment count.
Event::IssueOpened { .. }
| Event::IssueCommented { .. }
| Event::IssueClosed { .. } => {}
}
}
state
}
/// **One issue**, folded from its events.
///
/// Unlike [`Review`], this owns its whole state — the title, the body, whether
/// it is open — because unlike a review there is no [`crate::pulls`] record
/// behind it. An issue is nothing but the fold of its signed events, so the
/// fold is the only authority for what it says.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Issue {
/// The number the fold assigned: the Nth [`Event::IssueOpened`] in the
/// repository is issue N.
pub number: u64,
/// The one-line summary the opener signed.
pub title: String,
/// The body the opener signed.
pub body: String,
/// The ACL principal that opened it — the opening event's actor.
pub author: String,
/// Whether it is open right now. An [`Event::IssueClosed`] shuts it.
pub open: bool,
/// How many comments have been made.
pub comments: usize,
/// When it was opened.
pub opened_at_unix_ms: i64,
}
/// **Fold every issue in a repository**, in the order it was opened.
///
/// The caller hands in one repository's events, in log order. Numbering is
/// positional and therefore reproducible: the first [`Event::IssueOpened`] is
/// issue 1, the second is issue 2, and a comment or close names the number it
/// is about in [`ForgeEvent::pull`]. An event naming a number nothing opened is
/// dropped — it is evidence about nothing — the same way [`fold`] drops another
/// pull request's events.
///
/// Review events (`Commented`/`Approved`/`Rejected`) are ignored: they are a
/// different subject, so a whole repository's forge log may be handed in.
pub fn fold_issues(events: &[ForgeEvent]) -> Vec<Issue> {
let mut issues: Vec<Issue> = Vec::new();
for event in events {
match &event.event {
Event::IssueOpened { title, body } => {
let number = issues.len() as u64 + 1;
issues.push(Issue {
number,
title: title.clone(),
body: body.clone(),
author: event.actor.clone(),
open: true,
comments: 0,
opened_at_unix_ms: event.at_unix_ms,
});
}
Event::IssueCommented { .. } => {
if let Some(issue) = issues.iter_mut().find(|i| i.number == event.pull) {
issue.comments += 1;
}
}
Event::IssueClosed { .. } => {
if let Some(issue) = issues.iter_mut().find(|i| i.number == event.pull) {
issue.open = false;
}
}
Event::Commented { .. } | Event::Approved { .. } | Event::Rejected { .. } => {}
}
}
issues
}
/// **One issue**, folded, or `None` if nothing opened that number.
///
/// `None` here is a real answer and not [`fold`]'s deliberate always-`Some`: a
/// review folds to an empty review because whether the pull request exists is
/// [`crate::pulls`]'s question, but whether an issue exists is exactly THIS
/// fold's question — there is no other owner to ask — so a number nobody opened
/// is `None`.
pub fn fold_issue(number: u64, events: &[ForgeEvent]) -> Option<Issue> {
fold_issues(events).into_iter().find(|i| i.number == number)
}
/// One inline comment, extracted from the review log and shaped for rendering
/// on a diff.
///
/// Deliberately carries its own `blob_oid`: the caller matches it against a
/// file's blob in the diff it is showing, and that match — not the pull
/// request's head commit — is what puts the comment on the right line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineComment {
/// The ACL principal who wrote it.
pub actor: String,
/// The file it is on.
pub path: String,
/// The one-based line within `blob_oid`.
pub line: u32,
/// The blob the commented line lived in.
pub blob_oid: String,
/// What was said.
pub body: String,
/// When.
pub at_unix_ms: i64,
}
/// Every INLINE comment on one pull request, in event order.
///
/// A `Commented` with an empty `path` is a comment on the pull request as a
/// whole — it is counted by [`fold`] as a comment and does not appear here,
/// because it has no line to sit on.
pub fn inline_comments(pull: u64, events: &[ForgeEvent]) -> Vec<InlineComment> {
events
.iter()
.filter(|e| e.pull == pull)
.filter_map(|e| match &e.event {
Event::Commented {
body,
path,
line,
blob_oid,
} if !path.is_empty() => Some(InlineComment {
actor: e.actor.clone(),
path: path.clone(),
line: *line,
blob_oid: blob_oid.clone(),
body: body.clone(),
at_unix_ms: e.at_unix_ms,
}),
_ => None,
})
.collect()
}
/// The inline comments that belong on `path` at blob `blob_oid`.
///
/// **Matched by the blob, never by the pull request's head.** The whole point
/// of anchoring a comment to a blob is that the blob is the exact bytes the
/// line was written against: give this the blob a file has in the diff being
/// rendered and it returns only the comments that were made on THAT version of
/// the file. A rebase that left the file alone hands back the same blob and the
/// comments still attach; a rebase that rewrote the file hands back a different
/// blob and they do not, because the line they named is gone.
pub fn comments_on_blob<'a>(
comments: &'a [InlineComment],
path: &str,
blob_oid: &str,
) -> Vec<&'a InlineComment> {
comments
.iter()
.filter(|c| c.path == path && c.blob_oid == blob_oid)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn event(pull: u64, actor: &str, at: i64, event: Event) -> ForgeEvent {
ForgeEvent {
repo: "team/repo".into(),
pull,
actor: actor.into(),
at_unix_ms: at,
event,
}
}
fn key() -> ssh_key::PrivateKey {
ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
}
/// A comment on the pull request as a whole — no inline anchor.
fn plain(body: &str) -> Event {
Event::Commented {
body: body.into(),
path: String::new(),
line: 0,
blob_oid: String::new(),
}
}
/// ★ **Gunnar's attestation is inside its signature** (GHUNT-19): an
/// attested event verifies against gunnar's key, and taking the attestation
/// off, moving its time, or re-naming the actor breaks it.
#[test]
fn an_attested_event_binds_its_attestation_and_its_actor() {
let gunnar = key();
let attested = attest_event(
&gunnar,
event(
7,
"bob",
1_700_000_000_000,
Event::Approved {
commit: "a".repeat(40),
},
),
Attested {
session_stepped_up_at_unix_ms: 1_699_999_990_000,
},
)
.expect("attest");
assert_eq!(verify_event(&attested, gunnar.public_key()), Ok(()));
assert_eq!(
attested.attested.expect("carried").words(),
"attested for session step-up at 2023-11-14T22:13:10Z"
);
let mut stripped = attested.clone();
stripped.attested = None;
assert!(
verify_event(&stripped, gunnar.public_key()).is_err(),
"the attestation came off"
);
let mut moved = attested.clone();
moved.attested = Some(Attested {
session_stepped_up_at_unix_ms: 1,
});
assert!(
verify_event(&moved, gunnar.public_key()).is_err(),
"the step-up time moved"
);
let mut renamed = attested;
renamed.event.actor = "mallory".into();
assert!(
verify_event(&renamed, gunnar.public_key()).is_err(),
"the actor moved"
);
}
/// **An approval is evidence**: signed by the reviewer, over the commit
/// they reviewed, and every field bound.
#[test]
fn an_approval_verifies_and_no_field_can_be_moved_under_it() {
let alice = key();
let mallory = key();
let signed = sign_event(
&alice,
event(
7,
"alice",
1_700_000_000_000,
Event::Approved {
commit: "a".repeat(40),
},
),
)
.expect("sign");
assert_eq!(verify_event(&signed, alice.public_key()), Ok(()));
assert!(matches!(
verify_event(&signed, mallory.public_key()),
Err(EventProblem::WrongKey { .. })
));
// The commit above all: an approval that can be re-pointed is an
// approval a force-push inherits.
for mutate in [
|e: &mut ForgeEvent| {
e.event = Event::Approved {
commit: "b".repeat(40),
}
},
|e: &mut ForgeEvent| e.actor = "mallory".into(),
|e: &mut ForgeEvent| e.pull = 8,
|e: &mut ForgeEvent| e.repo = "other/repo".into(),
|e: &mut ForgeEvent| e.at_unix_ms += 1,
] {
let mut tampered = signed.clone();
mutate(&mut tampered.event);
assert_eq!(
verify_event(&tampered, alice.public_key()),
Err(EventProblem::NotVerified),
"a field moved under the signature: {:?}",
tampered.event
);
}
}
/// A signature made under another namespace is refused — so a **commit**
/// signature can never be presented as an approval.
#[test]
fn a_commit_signature_cannot_be_presented_as_an_approval() {
let alice = key();
let event = event(
1,
"alice",
1,
Event::Approved {
commit: "c".repeat(40),
},
);
let elsewhere = alice
.sign("git", ssh_key::HashAlg::Sha512, &event.signing_bytes())
.expect("sign");
let smuggled = SignedEvent {
key_fingerprint: alice
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string(),
signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("armour"),
event,
attested: None,
};
assert!(matches!(
verify_event(&smuggled, alice.public_key()),
Err(EventProblem::Malformed(_))
));
}
/// **A comment with a newline cannot forge a field.** The body is
/// length-prefixed, so text that looks like `\nactor mallory` is text.
#[test]
fn a_body_cannot_smuggle_a_field_past_the_signature() {
let honest = event(1, "alice", 1, plain("looks fine"));
let sneaky = event(1, "alice", 1, plain("looks fine\nactor mallory"));
assert_ne!(honest.signing_bytes(), sneaky.signing_bytes());
// And the length prefix is what does it: the two bodies differ in
// length, so no padding can make them agree.
let text = String::from_utf8(sneaky.signing_bytes()).expect("utf-8");
assert!(
text.contains("body 24 looks fine\nactor mallory"),
"the body must be length-prefixed: {text}"
);
}
/// The fold is the review, and it answers **who approved what**.
#[test]
fn the_fold_tracks_approvals_by_actor_and_by_commit() {
let old = "1".repeat(40);
let new = "2".repeat(40);
let events = vec![
event(1, "bob", 2, plain("ok")),
event(
1,
"bob",
3,
Event::Approved {
commit: old.clone(),
},
),
event(
1,
"carol",
4,
Event::Approved {
commit: old.clone(),
},
),
// Another pull request's events must not leak into this fold.
event(
2,
"dave",
5,
Event::Approved {
commit: new.clone(),
},
),
];
let review = fold(1, &events);
assert_eq!(review.comments, 1);
let mut approvers = review.approvers_of(&old);
approvers.sort();
assert_eq!(approvers, vec!["bob", "carol"]);
// **A new commit is not approved just because the old one was.**
assert!(
review.approvers_of(&new).is_empty(),
"an approval of an earlier commit was counted for a later one, so a force-push \
inherits a review"
);
}
/// A withdrawal removes that actor's approval and nobody else's.
#[test]
fn a_withdrawal_removes_one_approval_and_leaves_the_others() {
let commit = "3".repeat(40);
let events = vec![
event(
1,
"bob",
2,
Event::Approved {
commit: commit.clone(),
},
),
event(
1,
"carol",
3,
Event::Approved {
commit: commit.clone(),
},
),
event(
1,
"bob",
4,
Event::Rejected {
commit: commit.clone(),
reason: "second thoughts".into(),
},
),
];
assert_eq!(fold(1, &events).approvers_of(&commit), vec!["carol"]);
}
/// **A pull request nobody has reviewed folds to an empty review, not to
/// nothing.**
///
/// Whether the pull request exists is `crate::pulls`'s question. Answering
/// `None` here would be this module having an opinion about a record it
/// does not own — and "nobody has approved it" is the answer a merge gate
/// needs, which is not the same as "there is no such pull request".
#[test]
fn an_unreviewed_pull_folds_to_an_empty_review() {
let none = fold(9, &[]);
assert_eq!(none.pull, 9);
assert!(none.approvals.is_empty());
assert_eq!(none.comments, 0);
assert!(none.approvers_of(&"4".repeat(40)).is_empty());
}
/// **An issue opened is evidence**: signed by its author, over the exact
/// title and body, every field bound. The mutation loop is what makes this a
/// guard and not a round-trip — break any field and the signature must fail.
#[test]
fn an_issue_open_verifies_and_no_field_can_be_moved_under_it() {
let alice = key();
let mallory = key();
let signed = sign_event(
&alice,
event(
// `pull` is 0 for an opening: the number does not exist yet.
0,
"alice",
1_700_000_000_000,
Event::IssueOpened {
title: "the installer wedges on a 0600 kernel".into(),
body: "forge says no kernel to bundle".into(),
},
),
)
.expect("sign");
assert_eq!(verify_event(&signed, alice.public_key()), Ok(()));
// A different key is not this author.
assert!(matches!(
verify_event(&signed, mallory.public_key()),
Err(EventProblem::WrongKey { .. })
));
for mutate in [
|e: &mut ForgeEvent| {
e.event = Event::IssueOpened {
title: "something else entirely".into(),
body: "forge says no kernel to bundle".into(),
}
},
|e: &mut ForgeEvent| {
e.event = Event::IssueOpened {
title: "the installer wedges on a 0600 kernel".into(),
body: "a body nobody wrote".into(),
}
},
|e: &mut ForgeEvent| e.actor = "mallory".into(),
|e: &mut ForgeEvent| e.repo = "other/repo".into(),
] {
let mut tampered = signed.clone();
mutate(&mut tampered.event);
assert_eq!(
verify_event(&tampered, alice.public_key()),
Err(EventProblem::NotVerified),
"a field moved under the signature: {:?}",
tampered.event
);
}
}
/// **The fold is the issue**: numbering is positional, a close shuts it, a
/// comment on a number nobody opened is dropped, and one issue's comment
/// does not land on another.
#[test]
fn issues_fold_by_position_and_a_close_shuts_the_right_one() {
let events = vec![
event(
0,
"alice",
10,
Event::IssueOpened {
title: "first".into(),
body: "one".into(),
},
),
event(
0,
"bob",
11,
Event::IssueOpened {
title: "second".into(),
body: "two".into(),
},
),
event(
1,
"carol",
12,
Event::IssueCommented {
body: "on #1".into(),
},
),
event(
2,
"carol",
13,
Event::IssueCommented {
body: "on #2".into(),
},
),
event(
2,
"dave",
14,
Event::IssueCommented {
body: "also #2".into(),
},
),
// A comment on an issue nobody opened is evidence about nothing.
event(
9,
"eve",
15,
Event::IssueCommented {
body: "ghost".into(),
},
),
event(
1,
"alice",
16,
Event::IssueClosed {
reason: "fixed".into(),
},
),
];
let issues = fold_issues(&events);
assert_eq!(issues.len(), 2, "two openings, two issues");
let one = fold_issue(1, &events).expect("issue 1");
assert_eq!(one.number, 1);
assert_eq!(one.title, "first");
assert_eq!(one.author, "alice");
assert_eq!(one.comments, 1, "only #1's own comment counts for #1");
assert!(!one.open, "an IssueClosed shuts it");
let two = fold_issue(2, &events).expect("issue 2");
assert_eq!(two.comments, 2);
assert!(two.open, "closing #1 must not touch #2");
// A number nobody opened is None — this fold IS the authority for
// whether the issue exists, so it says so.
assert!(fold_issue(9, &events).is_none());
}
/// **A review event never leaks into an issue, and the reverse.** They share
/// the subject number but not the kind, and the kind is what the fold keys
/// on — so a pull request #1 comment cannot inflate issue #1.
#[test]
fn a_review_comment_is_not_an_issue_comment() {
let events = vec![
event(
0,
"alice",
1,
Event::IssueOpened {
title: "an issue".into(),
body: "b".into(),
},
),
// Same number as the issue, but a REVIEW comment.
event(1, "bob", 2, plain("on the PR")),
];
assert_eq!(fold_issue(1, &events).expect("issue 1").comments, 0);
// And the review fold sees its own comment but NOT the issue opening:
// one review comment, and the IssueOpened contributes nothing to it.
assert_eq!(fold(1, &events).comments, 1);
}
/// **An inline comment follows the blob, not the pull request.**
///
/// The comment is made on `src/lib.rs` at line 42, against the blob that
/// file had when it was reviewed. An unrelated commit later in the pull
/// request changes the head but leaves `src/lib.rs` — and therefore its blob
/// oid — alone, so the comment is still on line 42. Rewrite the file and its
/// blob changes, so the comment no longer attaches: the line it named is
/// gone. A matcher keyed on the pull number would have kept it on the
/// rewritten file, which is the bug this anchoring exists to avoid.
#[test]
fn an_inline_comment_follows_the_blob_not_the_pull() {
let reviewed = "a".repeat(40);
let rewritten = "b".repeat(40);
let events = vec![
event(
1,
"alice",
10,
Event::Commented {
body: "off-by-one here".into(),
path: "src/lib.rs".into(),
line: 42,
blob_oid: reviewed.clone(),
},
),
// A per-pull comment must not be mistaken for an inline one: it has
// no line to sit on.
event(1, "bob", 11, plain("LGTM overall")),
// Another pull request's inline comment must not leak in.
event(
2,
"carol",
12,
Event::Commented {
body: "different pr".into(),
path: "src/lib.rs".into(),
line: 42,
blob_oid: reviewed.clone(),
},
),
];
let inline = inline_comments(1, &events);
assert_eq!(
inline.len(),
1,
"the per-pull comment and the other pull's comment must not appear as this pull's \
inline comments"
);
// The file was untouched by the unrelated commit: same blob, so the
// comment is still on its line, with its exact author and body.
let stays = comments_on_blob(&inline, "src/lib.rs", &reviewed);
assert_eq!(stays.len(), 1);
assert_eq!(stays[0].line, 42);
assert_eq!(stays[0].actor, "alice");
assert_eq!(stays[0].body, "off-by-one here");
// The file was rewritten: a different blob oid. The comment does NOT
// attach — anchoring to the pull number would have wrongly shown it on
// a file version it was never made on.
assert!(
comments_on_blob(&inline, "src/lib.rs", &rewritten).is_empty(),
"the comment attached to a blob it was never made on: it is keyed on the pull, not \
the code"
);
}
/// **Every field of the inline anchor is bound by the signature**, and a
/// per-pull comment's bytes are unchanged from before the anchor existed.
#[test]
fn an_inline_anchor_is_bound_and_a_plain_comment_is_unchanged() {
let base = event(
1,
"alice",
1,
Event::Commented {
body: "here".into(),
path: "a.rs".into(),
line: 5,
blob_oid: "a".repeat(40),
},
);
for mutate in [
|e: &mut ForgeEvent| {
if let Event::Commented { path, .. } = &mut e.event {
*path = "b.rs".into();
}
},
|e: &mut ForgeEvent| {
if let Event::Commented { line, .. } = &mut e.event {
*line = 6;
}
},
|e: &mut ForgeEvent| {
if let Event::Commented { blob_oid, .. } = &mut e.event {
*blob_oid = "b".repeat(40);
}
},
] {
let mut moved = base.clone();
mutate(&mut moved);
assert_ne!(
base.signing_bytes(),
moved.signing_bytes(),
"an inline anchor field moved without changing what is signed: {:?}",
moved.event
);
}
// A per-pull comment signs exactly what it did before inline comments
// were a thing: `body` and nothing after it.
let text =
String::from_utf8(event(1, "alice", 1, plain("hi")).signing_bytes()).expect("utf-8");
assert!(
text.ends_with("kind commented\nbody 2 hi\n"),
"a per-pull comment's signed bytes changed: {text}"
);
}
}