e953328a
Read patches written by older git-collab versions
a73x 2026-08-08 18:44
Commit message
src/event.rs
| Old | New | ||
|---|---|---|---|
| @@ -48,6 +48,18 @@ pub enum Action { | |||
| 48 | IssueCommitLink { | 48 | IssueCommitLink { |
| 49 | commit: String, | 49 | commit: String, |
| 50 | }, | 50 | }, |
| 51 | /// Patches created before revisions recorded their commit and tree omit | ||
| 52 | /// both fields; an older shape still calls the whole variant `PatchCreate` | ||
| 53 | /// and puts the head in `head_commit` (sometimes a branch name, sometimes | ||
| 54 | /// a raw OID — `PatchState::resolve_head` handles either). Defaulting | ||
| 55 | /// `commit`/`tree` to empty keeps those patches readable; an empty commit | ||
| 56 | /// means "not recorded", never "the null OID". | ||
| 57 | /// | ||
| 58 | /// Skipping the empty case on the way out matters as much as defaulting it | ||
| 59 | /// on the way in: signatures are checked by re-serializing the event, so | ||
| 60 | /// writing back a field the signer never wrote would invalidate every | ||
| 61 | /// legacy signature and `sync` would reject the ref. Nothing current code | ||
| 62 | /// writes is ever empty, so this never fires for a new event. | ||
| 51 | #[serde(rename = "patch.create", alias = "PatchCreate")] | 63 | #[serde(rename = "patch.create", alias = "PatchCreate")] |
| 52 | PatchCreate { | 64 | PatchCreate { |
| 53 | title: String, | 65 | title: String, |
| @@ -57,24 +69,44 @@ pub enum Action { | |||
| 57 | branch: String, | 69 | branch: String, |
| 58 | #[serde(default, skip_serializing_if = "Option::is_none")] | 70 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 59 | fixes: Option<String>, | 71 | fixes: Option<String>, |
| 72 | #[serde(default, skip_serializing_if = "String::is_empty")] | ||
| 60 | commit: String, | 73 | commit: String, |
| 74 | #[serde(default, skip_serializing_if = "String::is_empty")] | ||
| 61 | tree: String, | 75 | tree: String, |
| 62 | /// Base branch tip OID at creation time (for merge detection). | 76 | /// Base branch tip OID at creation time (for merge detection). |
| 63 | #[serde(default, skip_serializing_if = "Option::is_none")] | 77 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 64 | base_commit: Option<String>, | 78 | base_commit: Option<String>, |
| 65 | }, | 79 | }, |
| 66 | #[serde(rename = "patch.revision")] | 80 | /// Historically written as `patch.revise` carrying only a note, before |
| 81 | /// revisions recorded the commit and tree they pointed at. Note that the | ||
| 82 | /// variant rename is not reversible on serialization, so these events | ||
| 83 | /// cannot round-trip for signature purposes however `commit`/`tree` are | ||
| 84 | /// handled — see issue 2a79b3ab. | ||
| 85 | #[serde(rename = "patch.revision", alias = "patch.revise")] | ||
| 67 | PatchRevision { | 86 | PatchRevision { |
| 87 | #[serde(default, skip_serializing_if = "String::is_empty")] | ||
| 68 | commit: String, | 88 | commit: String, |
| 89 | #[serde(default, skip_serializing_if = "String::is_empty")] | ||
| 69 | tree: String, | 90 | tree: String, |
| 70 | #[serde(default, skip_serializing_if = "Option::is_none")] | 91 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 71 | body: Option<String>, | 92 | body: Option<String>, |
| 72 | }, | 93 | }, |
| 94 | /// `revision` is `None` on reviews written before reviews were scoped to a | ||
| 95 | /// revision. It is not the same as revision 1: `PatchState` attributes | ||
| 96 | /// such a review to whichever revision was current when it was written, | ||
| 97 | /// which is what the vote-per-revision rule needs to keep successive | ||
| 98 | /// review rounds by one author from superseding each other. | ||
| 99 | /// | ||
| 100 | /// A `Some` revision is part of the signed payload. A `None` one is | ||
| 101 | /// recovered from the event's position in the DAG, which is a weaker | ||
| 102 | /// guarantee — see the invariant documented at the attribution site in | ||
| 103 | /// `PatchState::from_ref_uncached`, and issue 33b5e541. | ||
| 73 | #[serde(rename = "patch.review")] | 104 | #[serde(rename = "patch.review")] |
| 74 | PatchReview { | 105 | PatchReview { |
| 75 | verdict: ReviewVerdict, | 106 | verdict: ReviewVerdict, |
| 76 | body: String, | 107 | body: String, |
| 77 | revision: u32, | 108 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 109 | revision: Option<u32>, | ||
| 78 | }, | 110 | }, |
| 79 | #[serde(rename = "patch.comment")] | 111 | #[serde(rename = "patch.comment")] |
| 80 | PatchComment { body: String }, | 112 | PatchComment { body: String }, |
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -19,17 +19,25 @@ fn auto_detect_revision( | |||
| 19 | ) -> Result<Option<Revision>, Error> { | 19 | ) -> Result<Option<Revision>, Error> { |
| 20 | let current_rev = patch.revisions.last().map(|r| r.number).unwrap_or(0); | 20 | let current_rev = patch.revisions.last().map(|r| r.number).unwrap_or(0); |
| 21 | 21 | ||
| 22 | let last_commit = patch | ||
| 23 | .revisions | ||
| 24 | .last() | ||
| 25 | .map(|r| r.commit.as_str()) | ||
| 26 | .unwrap_or(""); | ||
| 27 | |||
| 28 | // Legacy patches recorded no commit on their revisions, so there is | ||
| 29 | // nothing to compare the branch tip against. Every tip would look like a | ||
| 30 | // change and append a revision that never happened. | ||
| 31 | if last_commit.is_empty() { | ||
| 32 | return Ok(None); | ||
| 33 | } | ||
| 34 | |||
| 22 | // Try to resolve the branch tip | 35 | // Try to resolve the branch tip |
| 23 | let tip_oid = match patch.resolve_head(repo) { | 36 | let tip_oid = match patch.resolve_head(repo) { |
| 24 | Ok(oid) => oid, | 37 | Ok(oid) => oid, |
| 25 | Err(_) => return Ok(None), // Branch deleted or unavailable | 38 | Err(_) => return Ok(None), // Branch deleted or unavailable |
| 26 | }; | 39 | }; |
| 27 | 40 | ||
| 28 | let last_commit = patch | ||
| 29 | .revisions | ||
| 30 | .last() | ||
| 31 | .map(|r| r.commit.as_str()) | ||
| 32 | .unwrap_or(""); | ||
| 33 | let tip_hex = tip_oid.to_string(); | 41 | let tip_hex = tip_oid.to_string(); |
| 34 | 42 | ||
| 35 | if tip_hex == last_commit { | 43 | if tip_hex == last_commit { |
| @@ -352,7 +360,7 @@ pub fn review( | |||
| 352 | action: Action::PatchReview { | 360 | action: Action::PatchReview { |
| 353 | verdict, | 361 | verdict, |
| 354 | body: body.to_string(), | 362 | body: body.to_string(), |
| 355 | revision: rev, | 363 | revision: Some(rev), |
| 356 | }, | 364 | }, |
| 357 | clock: 0, | 365 | clock: 0, |
| 358 | }; | 366 | }; |
| @@ -563,11 +571,9 @@ pub fn patch_log_to_writer( | |||
| 563 | } | 571 | } |
| 564 | 572 | ||
| 565 | for (i, rev) in patch.revisions.iter().enumerate() { | 573 | for (i, rev) in patch.revisions.iter().enumerate() { |
| 566 | let short_oid = if rev.commit.len() >= 8 { | 574 | let short_oid = rev |
| 567 | &rev.commit[..8] | 575 | .short_commit() |
| 568 | } else { | 576 | .unwrap_or_else(|| state::UNKNOWN_COMMIT.to_string()); |
| 569 | &rev.commit | ||
| 570 | }; | ||
| 571 | let label = if i == 0 { " (initial)" } else { "" }; | 577 | let label = if i == 0 { " (initial)" } else { "" }; |
| 572 | let body_display = rev | 578 | let body_display = rev |
| 573 | .body | 579 | .body |
src/server/http/repo/patches.rs
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,12 @@ pub struct PatchesTemplate { | |||
| 31 | pub struct RevisionView { | 31 | pub struct RevisionView { |
| 32 | pub number: u32, | 32 | pub number: u32, |
| 33 | pub commit: String, | 33 | pub commit: String, |
| 34 | /// Abbreviated commit for display, or `None` when the revision recorded no | ||
| 35 | /// commit — patches created before revisions carried one. Truncated here | ||
| 36 | /// rather than in the template, where an out-of-range slice would panic | ||
| 37 | /// the request handler and drop the connection. The `None` case renders as | ||
| 38 | /// `state::UNKNOWN_COMMIT`, spelled out in the template. | ||
| 39 | pub short_commit: Option<String>, | ||
| 34 | pub timestamp: String, | 40 | pub timestamp: String, |
| 35 | pub body: Option<String>, | 41 | pub body: Option<String>, |
| 36 | } | 42 | } |
| @@ -153,6 +159,7 @@ pub async fn patch_detail( | |||
| 153 | .into_iter() | 159 | .into_iter() |
| 154 | .map(|r| RevisionView { | 160 | .map(|r| RevisionView { |
| 155 | number: r.number, | 161 | number: r.number, |
| 162 | short_commit: r.short_commit(), | ||
| 156 | commit: r.commit, | 163 | commit: r.commit, |
| 157 | timestamp: r.timestamp, | 164 | timestamp: r.timestamp, |
| 158 | body: r.body, | 165 | body: r.body, |
src/server/http/templates/patch_detail.html
| Old | New | ||
|---|---|---|---|
| @@ -30,7 +30,7 @@ | |||
| 30 | {% for rev in patch.revisions %} | 30 | {% for rev in patch.revisions %} |
| 31 | <tr> | 31 | <tr> |
| 32 | <td>{{ rev.number }}</td> | 32 | <td>{{ rev.number }}</td> |
| 33 | <td class="mono"><a href="/{{ repo_name }}/diff/{{ rev.commit }}">{{ rev.commit[..8] }}</a></td> | 33 | <td class="mono">{% if let Some(short) = rev.short_commit %}<a href="/{{ repo_name }}/diff/{{ rev.commit }}">{{ short }}</a>{% else %}<span style="color: #666;">unknown</span>{% endif %}</td> |
| 34 | <td class="mono" style="color: #666;">{{ rev.timestamp }}</td> | 34 | <td class="mono" style="color: #666;">{{ rev.timestamp }}</td> |
| 35 | <td>{% if let Some(body) = rev.body %}{{ body }}{% endif %}</td> | 35 | <td>{% if let Some(body) = rev.body %}{{ body }}{% endif %}</td> |
| 36 | </tr> | 36 | </tr> |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -161,12 +161,31 @@ impl fmt::Display for PatchStatus { | |||
| 161 | #[derive(Debug, Clone, Serialize, Deserialize)] | 161 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 162 | pub struct Revision { | 162 | pub struct Revision { |
| 163 | pub number: u32, | 163 | pub number: u32, |
| 164 | /// Commit OID, or `""` when none was recorded — patches created before | ||
| 165 | /// revisions carried one. Consumers of the JSON output must treat the | ||
| 166 | /// empty string as "unknown", not as an OID: it will not parse as one. | ||
| 164 | pub commit: String, | 167 | pub commit: String, |
| 168 | /// Tree OID, or `""` alongside an unrecorded `commit`. | ||
| 165 | pub tree: String, | 169 | pub tree: String, |
| 166 | pub body: Option<String>, | 170 | pub body: Option<String>, |
| 167 | pub timestamp: String, | 171 | pub timestamp: String, |
| 168 | } | 172 | } |
| 169 | 173 | ||
| 174 | impl Revision { | ||
| 175 | /// Abbreviated commit for display, or `None` when none was recorded. | ||
| 176 | /// Truncating by chars rather than bytes keeps this total for any input. | ||
| 177 | pub fn short_commit(&self) -> Option<String> { | ||
| 178 | if self.commit.is_empty() { | ||
| 179 | return None; | ||
| 180 | } | ||
| 181 | Some(self.commit.chars().take(8).collect()) | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | /// What to show in place of a commit that was never recorded. Shared so the | ||
| 186 | /// CLI, the TUI and the web UI say the same thing. | ||
| 187 | pub const UNKNOWN_COMMIT: &str = "unknown"; | ||
| 188 | |||
| 170 | #[derive(Debug, Clone, Serialize, Deserialize)] | 189 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 171 | pub struct InlineComment { | 190 | pub struct InlineComment { |
| 172 | pub author: Author, | 191 | pub author: Author, |
| @@ -551,8 +570,12 @@ impl PatchState { | |||
| 551 | } | 570 | } |
| 552 | Action::PatchRevision { commit, tree, body } => { | 571 | Action::PatchRevision { commit, tree, body } => { |
| 553 | if let Some(ref mut s) = state { | 572 | if let Some(ref mut s) = state { |
| 554 | // Dedup by commit OID — skip if already seen | 573 | // Dedup by commit OID — skip if already seen. Legacy |
| 555 | let already_seen = s.revisions.iter().any(|r| r.commit == commit); | 574 | // revisions recorded no commit, and collapsing those |
| 575 | // into one another would also collapse the review | ||
| 576 | // rounds that sat between them. | ||
| 577 | let already_seen = | ||
| 578 | !commit.is_empty() && s.revisions.iter().any(|r| r.commit == commit); | ||
| 556 | if !already_seen { | 579 | if !already_seen { |
| 557 | let number = s.revisions.len() as u32 + 1; | 580 | let number = s.revisions.len() as u32 + 1; |
| 558 | s.revisions.push(Revision { | 581 | s.revisions.push(Revision { |
| @@ -571,6 +594,42 @@ impl PatchState { | |||
| 571 | revision, | 594 | revision, |
| 572 | } => { | 595 | } => { |
| 573 | if let Some(ref mut s) = state { | 596 | if let Some(ref mut s) = state { |
| 597 | // Reviews written before reviews were revision-scoped | ||
| 598 | // carry no revision. Attribute them to the revision | ||
| 599 | // that was current at this point in the walk, which is | ||
| 600 | // where the reviewer was in fact looking. | ||
| 601 | // | ||
| 602 | // INVARIANT: this is only well-defined because every | ||
| 603 | // event that can reach this branch sits in a linear | ||
| 604 | // prefix of the DAG. Current code always writes | ||
| 605 | // `Some(n)`, so no new event extends the region where | ||
| 606 | // position matters, and no collab ref in the wild has | ||
| 607 | // a merge commit. Walk order over a *forked* DAG is a | ||
| 608 | // partial order that `Sort::TOPOLOGICAL` completes by | ||
| 609 | // an internal tiebreak, so two clients holding the | ||
| 610 | // same events joined in different parent orders could | ||
| 611 | // attribute the same review to different revisions. | ||
| 612 | // That is worse than a display bug: attribution feeds | ||
| 613 | // the vote-supersession rule below, so a divergence | ||
| 614 | // can drop a review rather than relabel one. For the | ||
| 615 | // same reason, note that signatures cover event | ||
| 616 | // content but not parent links — a chain rewrite | ||
| 617 | // (already unauthenticated) can now re-attribute | ||
| 618 | // someone else's legacy review and delete a vote | ||
| 619 | // through that same rule. The robust fix is to fold in | ||
| 620 | // a total order over `(clock, oid)`, which is | ||
| 621 | // content-derived and therefore client-independent, as | ||
| 622 | // the status fold below already does: issue 33b5e541. | ||
| 623 | // | ||
| 624 | // `state` is `Some` only after a PatchCreate, which | ||
| 625 | // always seeds revision 1, so the list is never empty | ||
| 626 | // here; `max(1)` guards a malformed DAG only. | ||
| 627 | debug_assert!( | ||
| 628 | !s.revisions.is_empty(), | ||
| 629 | "PatchCreate always seeds revision 1" | ||
| 630 | ); | ||
| 631 | let revision = | ||
| 632 | revision.unwrap_or_else(|| (s.revisions.len() as u32).max(1)); | ||
| 574 | // A reviewer holds one current vote per revision: a new | 633 | // A reviewer holds one current vote per revision: a new |
| 575 | // vote supersedes their previous one. Comment-verdict | 634 | // vote supersedes their previous one. Comment-verdict |
| 576 | // reviews are not votes and accumulate. | 635 | // reviews are not votes and accumulate. |
src/tui/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -422,7 +422,7 @@ mod tests { | |||
| 422 | let action = Action::PatchReview { | 422 | let action = Action::PatchReview { |
| 423 | verdict: ReviewVerdict::Approve, | 423 | verdict: ReviewVerdict::Approve, |
| 424 | body: "lgtm".to_string(), | 424 | body: "lgtm".to_string(), |
| 425 | revision: 1, | 425 | revision: Some(1), |
| 426 | }; | 426 | }; |
| 427 | assert_eq!(action_type_label(&action), "Patch Review"); | 427 | assert_eq!(action_type_label(&action), "Patch Review"); |
| 428 | } | 428 | } |
| @@ -492,7 +492,7 @@ mod tests { | |||
| 492 | action: Action::PatchReview { | 492 | action: Action::PatchReview { |
| 493 | verdict: ReviewVerdict::Approve, | 493 | verdict: ReviewVerdict::Approve, |
| 494 | body: "Looks good!".to_string(), | 494 | body: "Looks good!".to_string(), |
| 495 | revision: 1, | 495 | revision: Some(1), |
| 496 | }, | 496 | }, |
| 497 | clock: 0, | 497 | clock: 0, |
| 498 | }; | 498 | }; |
src/tui/widgets.rs
| Old | New | ||
|---|---|---|---|
| @@ -647,11 +647,9 @@ fn build_patch_detail_text(app: &App) -> Text<'static> { | |||
| 647 | .add_modifier(Modifier::BOLD), | 647 | .add_modifier(Modifier::BOLD), |
| 648 | )); | 648 | )); |
| 649 | for (i, rev) in patch.revisions.iter().enumerate() { | 649 | for (i, rev) in patch.revisions.iter().enumerate() { |
| 650 | let short = if rev.commit.len() >= 8 { | 650 | let short = rev |
| 651 | &rev.commit[..8] | 651 | .short_commit() |
| 652 | } else { | 652 | .unwrap_or_else(|| crate::state::UNKNOWN_COMMIT.to_string()); |
| 653 | &rev.commit | ||
| 654 | }; | ||
| 655 | let marker = if i == app.patch_revision_idx { | 653 | let marker = if i == app.patch_revision_idx { |
| 656 | "> " | 654 | "> " |
| 657 | } else { | 655 | } else { |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -195,6 +195,68 @@ pub fn init_repo(dir: &Path, author: &Author) -> Repository { | |||
| 195 | repo | 195 | repo |
| 196 | } | 196 | } |
| 197 | 197 | ||
| 198 | /// Append an event commit whose `action` JSON is supplied verbatim, with | ||
| 199 | /// `parent` as its only parent (`None` produces an orphan root commit). | ||
| 200 | /// Returns the new commit OID. The caller owns the ref. | ||
| 201 | /// | ||
| 202 | /// Historical on-disk shapes — `patch.revise`, reviews with no `revision`, | ||
| 203 | /// creates with no `commit`/`tree` — can no longer be produced by serializing | ||
| 204 | /// today's `Action`, so tests covering them write the JSON directly. The | ||
| 205 | /// commit tree and its ed25519 signature are built exactly as `dag` builds | ||
| 206 | /// them, so the result is indistinguishable from a real historical event. | ||
| 207 | pub fn write_raw_event( | ||
| 208 | repo: &Repository, | ||
| 209 | parent: Option<git2::Oid>, | ||
| 210 | action: serde_json::Value, | ||
| 211 | clock: u64, | ||
| 212 | ) -> git2::Oid { | ||
| 213 | use base64::engine::general_purpose::STANDARD; | ||
| 214 | use base64::Engine; | ||
| 215 | use ed25519_dalek::Signer; | ||
| 216 | |||
| 217 | let author = alice(); | ||
| 218 | let sk = test_signing_key(); | ||
| 219 | let event = serde_json::json!({ | ||
| 220 | "timestamp": now(), | ||
| 221 | "author": { "name": author.name, "email": author.email }, | ||
| 222 | "action": action, | ||
| 223 | "clock": clock, | ||
| 224 | }); | ||
| 225 | |||
| 226 | // serde_json::Value serializes with sorted keys, which is what | ||
| 227 | // signing::canonical_json relies on. | ||
| 228 | let canonical = serde_json::to_string(&event).unwrap(); | ||
| 229 | let signature = sk.sign(canonical.as_bytes()); | ||
| 230 | |||
| 231 | let event_blob = repo | ||
| 232 | .blob(serde_json::to_string_pretty(&event).unwrap().as_bytes()) | ||
| 233 | .unwrap(); | ||
| 234 | let sig_blob = repo | ||
| 235 | .blob(STANDARD.encode(signature.to_bytes()).as_bytes()) | ||
| 236 | .unwrap(); | ||
| 237 | let pubkey_blob = repo | ||
| 238 | .blob(STANDARD.encode(sk.verifying_key().to_bytes()).as_bytes()) | ||
| 239 | .unwrap(); | ||
| 240 | let manifest_blob = repo | ||
| 241 | .blob(br#"{"version":1,"format":"git-collab"}"#) | ||
| 242 | .unwrap(); | ||
| 243 | |||
| 244 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 245 | tb.insert("event.json", event_blob, 0o100644).unwrap(); | ||
| 246 | tb.insert("signature", sig_blob, 0o100644).unwrap(); | ||
| 247 | tb.insert("pubkey", pubkey_blob, 0o100644).unwrap(); | ||
| 248 | tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); | ||
| 249 | let tree = repo.find_tree(tb.write().unwrap()).unwrap(); | ||
| 250 | |||
| 251 | let sig = git2::Signature::now(&author.name, &author.email).unwrap(); | ||
| 252 | let parents: Vec<git2::Commit> = parent | ||
| 253 | .map(|p| vec![repo.find_commit(p).unwrap()]) | ||
| 254 | .unwrap_or_default(); | ||
| 255 | let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); | ||
| 256 | repo.commit(None, &sig, &sig, "legacy event", &tree, &parent_refs) | ||
| 257 | .unwrap() | ||
| 258 | } | ||
| 259 | |||
| 198 | /// Open an issue using DAG primitives. Returns (ref_name, id). | 260 | /// Open an issue using DAG primitives. Returns (ref_name, id). |
| 199 | pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) { | 261 | pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) { |
| 200 | let sk = test_signing_key(); | 262 | let sk = test_signing_key(); |
| @@ -322,7 +384,7 @@ pub fn add_review_on( | |||
| 322 | action: Action::PatchReview { | 384 | action: Action::PatchReview { |
| 323 | verdict, | 385 | verdict, |
| 324 | body: "review comment".to_string(), | 386 | body: "review comment".to_string(), |
| 325 | revision, | 387 | revision: Some(revision), |
| 326 | }, | 388 | }, |
| 327 | clock: 0, | 389 | clock: 0, |
| 328 | }; | 390 | }; |
tests/fixtures/legacy_events/patch_create_head_commit_variant.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,14 @@ | |||
| 1 | { | ||
| 2 | "timestamp": "2026-03-21T15:42:16.382345234+00:00", | ||
| 3 | "author": { | ||
| 4 | "name": "a73x", | ||
| 5 | "email": "dev@a73x.sh" | ||
| 6 | }, | ||
| 7 | "action": { | ||
| 8 | "type": "PatchCreate", | ||
| 9 | "title": "Add --json output flag to list/show commands", | ||
| 10 | "body": "Fixes 79125c77. Adds --json flag to issue list, issue show, patch list, patch show for machine-parseable output in agent workflows.", | ||
| 11 | "base_ref": "main", | ||
| 12 | "head_commit": "db059e663d7a435f288bd2832bee4e9f374474d2" | ||
| 13 | } | ||
| 14 | } | ||
| \ No newline at end of file | 14 | \ No newline at end of file | |
tests/fixtures/legacy_events/patch_create_missing_commit_tree.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,16 @@ | |||
| 1 | { | ||
| 2 | "timestamp": "2026-03-21T19:08:48.630385084+00:00", | ||
| 3 | "author": { | ||
| 4 | "name": "a73x", | ||
| 5 | "email": "dev@a73x.sh" | ||
| 6 | }, | ||
| 7 | "action": { | ||
| 8 | "type": "patch.create", | ||
| 9 | "title": "Avoid double DAG walk after auto_detect_revision", | ||
| 10 | "body": "", | ||
| 11 | "base_ref": "main", | ||
| 12 | "branch": "worktree-agent-aef11cfd", | ||
| 13 | "fixes": "53e698d5" | ||
| 14 | }, | ||
| 15 | "clock": 1 | ||
| 16 | } | ||
| \ No newline at end of file | 16 | \ No newline at end of file | |
tests/fixtures/legacy_events/patch_review_missing_revision.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,13 @@ | |||
| 1 | { | ||
| 2 | "timestamp": "2026-03-21T19:09:05.299717785+00:00", | ||
| 3 | "author": { | ||
| 4 | "name": "a73x", | ||
| 5 | "email": "dev@a73x.sh" | ||
| 6 | }, | ||
| 7 | "action": { | ||
| 8 | "type": "patch.review", | ||
| 9 | "verdict": "RequestChanges", | ||
| 10 | "body": "Good approach returning Option<Revision> instead of u32 — that's the right move. But you've introduced the same 3-line pattern in comment(), review(), and merge():\n\n let mut patch = patch;\n if let Some(rev) = auto_detect_revision(repo, &ref_name, &patch, &sk)? {\n patch.revisions.push(rev);\n }\n\nExtract that into a small helper, e.g. `auto_detect_and_update(repo, ref_name, &mut patch, &sk)?` that does the detect + push in one call. That way callers just need one line. Also avoids the awkward `let mut patch = patch;` rebinding." | ||
| 11 | }, | ||
| 12 | "clock": 2 | ||
| 13 | } | ||
| \ No newline at end of file | 13 | \ No newline at end of file | |
tests/fixtures/legacy_events/patch_review_with_revision.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,14 @@ | |||
| 1 | { | ||
| 2 | "timestamp": "2026-03-22T10:03:14.967814484+00:00", | ||
| 3 | "author": { | ||
| 4 | "name": "a73x", | ||
| 5 | "email": "dev@a73x.sh" | ||
| 6 | }, | ||
| 7 | "action": { | ||
| 8 | "type": "patch.review", | ||
| 9 | "verdict": "Approve", | ||
| 10 | "body": "LGTM — confirmed no stale .clone() calls remain. Good to merge.", | ||
| 11 | "revision": 2 | ||
| 12 | }, | ||
| 13 | "clock": 5 | ||
| 14 | } | ||
| \ No newline at end of file | 14 | \ No newline at end of file | |
tests/fixtures/legacy_events/patch_revise_body_only.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,12 @@ | |||
| 1 | { | ||
| 2 | "timestamp": "2026-03-21T19:10:36.869240870+00:00", | ||
| 3 | "author": { | ||
| 4 | "name": "a73x", | ||
| 5 | "email": "dev@a73x.sh" | ||
| 6 | }, | ||
| 7 | "action": { | ||
| 8 | "type": "patch.revise", | ||
| 9 | "body": "Address review: extract auto_detect_and_update helper to eliminate 3x repeated pattern" | ||
| 10 | }, | ||
| 11 | "clock": 3 | ||
| 12 | } | ||
| \ No newline at end of file | 12 | \ No newline at end of file | |
tests/legacy_patch_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,418 @@ | |||
| 1 | //! Recovery of patches written by older versions of git-collab. | ||
| 2 | //! | ||
| 3 | //! Between them, the historical shapes drop `commit`/`tree` from | ||
| 4 | //! `patch.create`, name the revision event `patch.revise`, and omit | ||
| 5 | //! `revision` from `patch.review`. Every event JSON asserted on here was | ||
| 6 | //! captured verbatim from a real patch in this repository's own collab refs | ||
| 7 | //! (`tests/fixtures/legacy_events/`). | ||
| 8 | |||
| 9 | mod common; | ||
| 10 | |||
| 11 | use std::path::PathBuf; | ||
| 12 | |||
| 13 | use common::{test_signing_key, write_raw_event, ServerHarness, TestRepo}; | ||
| 14 | use git_collab::event::{Action, Event, ReviewVerdict}; | ||
| 15 | use git_collab::signing::{self, DetachedSignature, VerifyStatus}; | ||
| 16 | use git_collab::state::{PatchState, PatchStatus}; | ||
| 17 | use serde_json::json; | ||
| 18 | |||
| 19 | fn fixture(name: &str) -> String { | ||
| 20 | let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) | ||
| 21 | .join("tests/fixtures/legacy_events") | ||
| 22 | .join(name); | ||
| 23 | std::fs::read_to_string(&path) | ||
| 24 | .unwrap_or_else(|e| panic!("reading fixture {}: {}", path.display(), e)) | ||
| 25 | } | ||
| 26 | |||
| 27 | /// Sign a fixture's bytes as its original author would have, then verify that | ||
| 28 | /// signature against the event as deserialized by current code. `Valid` means | ||
| 29 | /// the event survives the deserialize/re-serialize round trip byte for byte, | ||
| 30 | /// which is what decides whether `sync` accepts a ref carrying it. | ||
| 31 | fn round_trip_status(name: &str) -> VerifyStatus { | ||
| 32 | use base64::engine::general_purpose::STANDARD; | ||
| 33 | use base64::Engine; | ||
| 34 | use ed25519_dalek::Signer; | ||
| 35 | |||
| 36 | let raw = fixture(name); | ||
| 37 | let original: serde_json::Value = serde_json::from_str(&raw).unwrap(); | ||
| 38 | let canonical = serde_json::to_string(&original).unwrap(); | ||
| 39 | |||
| 40 | let sk = test_signing_key(); | ||
| 41 | let detached = DetachedSignature { | ||
| 42 | signature: STANDARD.encode(sk.sign(canonical.as_bytes()).to_bytes()), | ||
| 43 | pubkey: STANDARD.encode(sk.verifying_key().to_bytes()), | ||
| 44 | }; | ||
| 45 | |||
| 46 | let event: Event = serde_json::from_str(&raw).unwrap(); | ||
| 47 | signing::verify_detached(&event, &detached).unwrap() | ||
| 48 | } | ||
| 49 | |||
| 50 | // =========================================================================== | ||
| 51 | // Deserialization of real historical events | ||
| 52 | // =========================================================================== | ||
| 53 | |||
| 54 | #[test] | ||
| 55 | fn legacy_patch_create_without_commit_or_tree_deserializes() { | ||
| 56 | let event: Event = serde_json::from_str(&fixture("patch_create_missing_commit_tree.json")) | ||
| 57 | .expect("legacy patch.create must deserialize"); | ||
| 58 | |||
| 59 | match event.action { | ||
| 60 | Action::PatchCreate { | ||
| 61 | title, | ||
| 62 | base_ref, | ||
| 63 | branch, | ||
| 64 | fixes, | ||
| 65 | commit, | ||
| 66 | tree, | ||
| 67 | base_commit, | ||
| 68 | .. | ||
| 69 | } => { | ||
| 70 | assert_eq!(title, "Avoid double DAG walk after auto_detect_revision"); | ||
| 71 | assert_eq!(base_ref, "main"); | ||
| 72 | assert_eq!(branch, "worktree-agent-aef11cfd"); | ||
| 73 | assert_eq!(fixes.as_deref(), Some("53e698d5")); | ||
| 74 | assert!(commit.is_empty(), "unrecorded commit reads as empty"); | ||
| 75 | assert!(tree.is_empty(), "unrecorded tree reads as empty"); | ||
| 76 | assert!(base_commit.is_none()); | ||
| 77 | } | ||
| 78 | other => panic!("expected PatchCreate, got {:?}", other), | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | #[test] | ||
| 83 | fn legacy_patch_create_with_head_commit_deserializes() { | ||
| 84 | let event: Event = serde_json::from_str(&fixture("patch_create_head_commit_variant.json")) | ||
| 85 | .expect("legacy PatchCreate must deserialize"); | ||
| 86 | |||
| 87 | match event.action { | ||
| 88 | Action::PatchCreate { | ||
| 89 | title, | ||
| 90 | branch, | ||
| 91 | commit, | ||
| 92 | tree, | ||
| 93 | .. | ||
| 94 | } => { | ||
| 95 | assert_eq!(title, "Add --json output flag to list/show commands"); | ||
| 96 | // This generation recorded the head SHA in a field named | ||
| 97 | // `head_commit`, which is aliased onto `branch`. | ||
| 98 | assert_eq!(branch, "db059e663d7a435f288bd2832bee4e9f374474d2"); | ||
| 99 | assert!(commit.is_empty()); | ||
| 100 | assert!(tree.is_empty()); | ||
| 101 | } | ||
| 102 | other => panic!("expected PatchCreate, got {:?}", other), | ||
| 103 | } | ||
| 104 | assert_eq!(event.clock, 0, "this generation had no clock field"); | ||
| 105 | } | ||
| 106 | |||
| 107 | #[test] | ||
| 108 | fn legacy_patch_review_without_revision_deserializes() { | ||
| 109 | let event: Event = serde_json::from_str(&fixture("patch_review_missing_revision.json")) | ||
| 110 | .expect("legacy patch.review must deserialize"); | ||
| 111 | |||
| 112 | match event.action { | ||
| 113 | Action::PatchReview { | ||
| 114 | verdict, | ||
| 115 | body, | ||
| 116 | revision, | ||
| 117 | } => { | ||
| 118 | assert_eq!(verdict, ReviewVerdict::RequestChanges); | ||
| 119 | assert!(body.starts_with("Good approach returning Option<Revision>")); | ||
| 120 | assert_eq!(revision, None, "no revision was recorded"); | ||
| 121 | } | ||
| 122 | other => panic!("expected PatchReview, got {:?}", other), | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | #[test] | ||
| 127 | fn current_patch_review_keeps_its_revision() { | ||
| 128 | let event: Event = serde_json::from_str(&fixture("patch_review_with_revision.json")).unwrap(); | ||
| 129 | |||
| 130 | match event.action { | ||
| 131 | Action::PatchReview { | ||
| 132 | verdict, revision, .. | ||
| 133 | } => { | ||
| 134 | assert_eq!(verdict, ReviewVerdict::Approve); | ||
| 135 | assert_eq!(revision, Some(2)); | ||
| 136 | } | ||
| 137 | other => panic!("expected PatchReview, got {:?}", other), | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | #[test] | ||
| 142 | fn legacy_patch_revise_deserializes_as_a_revision() { | ||
| 143 | let event: Event = serde_json::from_str(&fixture("patch_revise_body_only.json")) | ||
| 144 | .expect("legacy patch.revise must deserialize"); | ||
| 145 | |||
| 146 | match event.action { | ||
| 147 | Action::PatchRevision { commit, tree, body } => { | ||
| 148 | assert!(commit.is_empty()); | ||
| 149 | assert!(tree.is_empty()); | ||
| 150 | assert_eq!( | ||
| 151 | body.as_deref(), | ||
| 152 | Some("Address review: extract auto_detect_and_update helper to eliminate 3x repeated pattern") | ||
| 153 | ); | ||
| 154 | } | ||
| 155 | other => panic!("expected PatchRevision, got {:?}", other), | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | // =========================================================================== | ||
| 160 | // Signature round-tripping (decides whether `sync` accepts these refs) | ||
| 161 | // =========================================================================== | ||
| 162 | |||
| 163 | #[test] | ||
| 164 | fn legacy_patch_create_signature_survives_the_round_trip() { | ||
| 165 | // Deserializing adds no `commit`/`tree` back, so the bytes the signer | ||
| 166 | // signed are the bytes we re-serialize. | ||
| 167 | assert_eq!( | ||
| 168 | round_trip_status("patch_create_missing_commit_tree.json"), | ||
| 169 | VerifyStatus::Valid | ||
| 170 | ); | ||
| 171 | } | ||
| 172 | |||
| 173 | #[test] | ||
| 174 | fn legacy_patch_review_signature_survives_the_round_trip() { | ||
| 175 | assert_eq!( | ||
| 176 | round_trip_status("patch_review_missing_revision.json"), | ||
| 177 | VerifyStatus::Valid | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | |||
| 181 | #[test] | ||
| 182 | fn current_event_signature_survives_the_round_trip() { | ||
| 183 | assert_eq!( | ||
| 184 | round_trip_status("patch_review_with_revision.json"), | ||
| 185 | VerifyStatus::Valid | ||
| 186 | ); | ||
| 187 | } | ||
| 188 | |||
| 189 | #[test] | ||
| 190 | fn renamed_legacy_shapes_still_cannot_round_trip() { | ||
| 191 | // `patch.revise` re-serializes as `patch.revision`, and the older | ||
| 192 | // `PatchCreate`/`head_commit` spelling normalizes to the current one, so | ||
| 193 | // no serde attribute can make these byte-identical. Their refs stay | ||
| 194 | // unsyncable until verification compares stored bytes — issue 2a79b3ab. | ||
| 195 | // Asserted so the day that changes, this test says so. | ||
| 196 | assert_eq!( | ||
| 197 | round_trip_status("patch_revise_body_only.json"), | ||
| 198 | VerifyStatus::Invalid | ||
| 199 | ); | ||
| 200 | assert_eq!( | ||
| 201 | round_trip_status("patch_create_head_commit_variant.json"), | ||
| 202 | VerifyStatus::Invalid | ||
| 203 | ); | ||
| 204 | } | ||
| 205 | |||
| 206 | // =========================================================================== | ||
| 207 | // Materializing a legacy DAG into PatchState | ||
| 208 | // =========================================================================== | ||
| 209 | |||
| 210 | /// Build the event chain of patch 05e5fa7c ("Avoid double DAG walk after | ||
| 211 | /// auto_detect_revision"): create, then three review rounds interleaved with | ||
| 212 | /// two revises, then a merge. Returns (ref_name, id). | ||
| 213 | fn write_legacy_review_history(repo: &git2::Repository) -> (String, String) { | ||
| 214 | let root = write_raw_event( | ||
| 215 | repo, | ||
| 216 | None, | ||
| 217 | json!({ | ||
| 218 | "type": "patch.create", | ||
| 219 | "title": "Avoid double DAG walk after auto_detect_revision", | ||
| 220 | "body": "", | ||
| 221 | "base_ref": "main", | ||
| 222 | "branch": "worktree-agent-aef11cfd", | ||
| 223 | "fixes": "53e698d5", | ||
| 224 | }), | ||
| 225 | 1, | ||
| 226 | ); | ||
| 227 | let mut tip = write_raw_event( | ||
| 228 | repo, | ||
| 229 | Some(root), | ||
| 230 | json!({ | ||
| 231 | "type": "patch.review", | ||
| 232 | "verdict": "RequestChanges", | ||
| 233 | "body": "Extract that into a small helper.", | ||
| 234 | }), | ||
| 235 | 2, | ||
| 236 | ); | ||
| 237 | tip = write_raw_event( | ||
| 238 | repo, | ||
| 239 | Some(tip), | ||
| 240 | json!({ | ||
| 241 | "type": "patch.revise", | ||
| 242 | "body": "Address review: extract auto_detect_and_update helper", | ||
| 243 | }), | ||
| 244 | 3, | ||
| 245 | ); | ||
| 246 | tip = write_raw_event( | ||
| 247 | repo, | ||
| 248 | Some(tip), | ||
| 249 | json!({ | ||
| 250 | "type": "patch.review", | ||
| 251 | "verdict": "RequestChanges", | ||
| 252 | "body": "One remaining nit: the let mut p = p rebinding.", | ||
| 253 | }), | ||
| 254 | 4, | ||
| 255 | ); | ||
| 256 | tip = write_raw_event( | ||
| 257 | repo, | ||
| 258 | Some(tip), | ||
| 259 | json!({ | ||
| 260 | "type": "patch.revise", | ||
| 261 | "body": "Address r2 feedback: remove let mut p = p rebinding", | ||
| 262 | }), | ||
| 263 | 5, | ||
| 264 | ); | ||
| 265 | tip = write_raw_event( | ||
| 266 | repo, | ||
| 267 | Some(tip), | ||
| 268 | json!({ | ||
| 269 | "type": "patch.review", | ||
| 270 | "verdict": "Approve", | ||
| 271 | "body": "Clean. Ship it.", | ||
| 272 | }), | ||
| 273 | 6, | ||
| 274 | ); | ||
| 275 | tip = write_raw_event(repo, Some(tip), json!({ "type": "patch.merge" }), 7); | ||
| 276 | |||
| 277 | let id = root.to_string(); | ||
| 278 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 279 | repo.reference(&ref_name, tip, false, "legacy patch") | ||
| 280 | .unwrap(); | ||
| 281 | (ref_name, id) | ||
| 282 | } | ||
| 283 | |||
| 284 | #[test] | ||
| 285 | fn legacy_patch_with_review_history_loads_intact() { | ||
| 286 | let work = TestRepo::new("Alice", "alice@example.com"); | ||
| 287 | let repo = git2::Repository::open(work.dir.path()).unwrap(); | ||
| 288 | let (ref_name, id) = write_legacy_review_history(&repo); | ||
| 289 | |||
| 290 | let patch = PatchState::from_ref_uncached(&repo, &ref_name, &id) | ||
| 291 | .expect("legacy patch must materialize"); | ||
| 292 | |||
| 293 | assert_eq!( | ||
| 294 | patch.title, | ||
| 295 | "Avoid double DAG walk after auto_detect_revision" | ||
| 296 | ); | ||
| 297 | assert_eq!(patch.branch, "worktree-agent-aef11cfd"); | ||
| 298 | assert_eq!(patch.status, PatchStatus::Merged); | ||
| 299 | |||
| 300 | // Each revise is its own revision even though none of them recorded a | ||
| 301 | // commit — collapsing them would also collapse the reviews between them. | ||
| 302 | let numbers: Vec<u32> = patch.revisions.iter().map(|r| r.number).collect(); | ||
| 303 | assert_eq!(numbers, vec![1, 2, 3]); | ||
| 304 | assert!(patch.revisions.iter().all(|r| r.commit.is_empty())); | ||
| 305 | assert_eq!( | ||
| 306 | patch.revisions[1].body.as_deref(), | ||
| 307 | Some("Address review: extract auto_detect_and_update helper") | ||
| 308 | ); | ||
| 309 | assert_eq!( | ||
| 310 | patch.revisions[2].body.as_deref(), | ||
| 311 | Some("Address r2 feedback: remove let mut p = p rebinding") | ||
| 312 | ); | ||
| 313 | |||
| 314 | // All three reviews survive: they are attributed to the revision that was | ||
| 315 | // current when each was written, so the one-vote-per-revision rule does | ||
| 316 | // not treat them as one author revoting on the same revision. | ||
| 317 | let verdicts: Vec<ReviewVerdict> = patch.reviews.iter().map(|r| r.verdict).collect(); | ||
| 318 | assert_eq!( | ||
| 319 | verdicts, | ||
| 320 | vec![ | ||
| 321 | ReviewVerdict::RequestChanges, | ||
| 322 | ReviewVerdict::RequestChanges, | ||
| 323 | ReviewVerdict::Approve | ||
| 324 | ] | ||
| 325 | ); | ||
| 326 | let review_revs: Vec<Option<u32>> = patch.reviews.iter().map(|r| r.revision).collect(); | ||
| 327 | assert_eq!(review_revs, vec![Some(1), Some(2), Some(3)]); | ||
| 328 | } | ||
| 329 | |||
| 330 | #[test] | ||
| 331 | fn legacy_patch_appears_in_patch_list() { | ||
| 332 | let work = TestRepo::new("Alice", "alice@example.com"); | ||
| 333 | let repo = git2::Repository::open(work.dir.path()).unwrap(); | ||
| 334 | write_legacy_review_history(&repo); | ||
| 335 | drop(repo); | ||
| 336 | |||
| 337 | let out = work.run_ok(&["patch", "list", "--all"]); | ||
| 338 | assert!( | ||
| 339 | out.contains("Avoid double DAG walk"), | ||
| 340 | "legacy patch missing from list output: {}", | ||
| 341 | out | ||
| 342 | ); | ||
| 343 | } | ||
| 344 | |||
| 345 | // =========================================================================== | ||
| 346 | // Writes against a legacy patch | ||
| 347 | // =========================================================================== | ||
| 348 | |||
| 349 | #[test] | ||
| 350 | fn commenting_on_a_legacy_patch_does_not_invent_a_revision() { | ||
| 351 | let work = TestRepo::new("Alice", "alice@example.com"); | ||
| 352 | let head = work.git(&["rev-parse", "HEAD"]).trim().to_string(); | ||
| 353 | let repo = git2::Repository::open(work.dir.path()).unwrap(); | ||
| 354 | |||
| 355 | // The oldest patches stored the head SHA where `branch` now lives, so the | ||
| 356 | // head still resolves even though revision 1 recorded no commit. | ||
| 357 | let root = write_raw_event( | ||
| 358 | &repo, | ||
| 359 | None, | ||
| 360 | json!({ | ||
| 361 | "type": "patch.create", | ||
| 362 | "title": "Legacy patch with resolvable head", | ||
| 363 | "body": "", | ||
| 364 | "base_ref": "main", | ||
| 365 | "head_commit": head, | ||
| 366 | }), | ||
| 367 | 1, | ||
| 368 | ); | ||
| 369 | let id = root.to_string(); | ||
| 370 | repo.reference( | ||
| 371 | &format!("refs/collab/patches/{}", id), | ||
| 372 | root, | ||
| 373 | false, | ||
| 374 | "legacy", | ||
| 375 | ) | ||
| 376 | .unwrap(); | ||
| 377 | drop(repo); | ||
| 378 | |||
| 379 | work.run_ok(&["patch", "comment", &id[..8], "-b", "a comment"]); | ||
| 380 | |||
| 381 | let out = work.run_ok(&["patch", "show", &id[..8], "--json"]); | ||
| 382 | let shown: serde_json::Value = serde_json::from_str(&out).unwrap(); | ||
| 383 | assert_eq!( | ||
| 384 | shown["revisions"].as_array().unwrap().len(), | ||
| 385 | 1, | ||
| 386 | "auto-detection must not turn an unrecorded commit into a new revision: {}", | ||
| 387 | out | ||
| 388 | ); | ||
| 389 | } | ||
| 390 | |||
| 391 | // =========================================================================== | ||
| 392 | // Server rendering | ||
| 393 | // =========================================================================== | ||
| 394 | |||
| 395 | #[test] | ||
| 396 | fn patch_detail_page_renders_a_revision_with_no_commit() { | ||
| 397 | let harness = ServerHarness::new("legacy-patch-detail"); | ||
| 398 | harness.push_head(); | ||
| 399 | |||
| 400 | let repo = git2::Repository::open(harness.work_repo().dir.path()).unwrap(); | ||
| 401 | let (_ref_name, id) = write_legacy_review_history(&repo); | ||
| 402 | drop(repo); | ||
| 403 | harness.push_collab_refs(); | ||
| 404 | |||
| 405 | let page = harness.get_ok(&format!("/{}/patches/{}", harness.repo_name(), id)); | ||
| 406 | assert!(page.body.contains("Avoid double DAG walk")); | ||
| 407 | assert!( | ||
| 408 | page.body.contains(">unknown<"), | ||
| 409 | "revisions with no commit should say so, in the same words as the CLI: {}", | ||
| 410 | page.body | ||
| 411 | ); | ||
| 412 | assert!( | ||
| 413 | !page.body.contains("/diff/\""), | ||
| 414 | "a revision with no commit must not link to an empty diff: {}", | ||
| 415 | page.body | ||
| 416 | ); | ||
| 417 | assert!(page.body.contains("Ship it."), "reviews should render"); | ||
| 418 | } | ||
tests/review_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -53,7 +53,7 @@ fn review_event( | |||
| 53 | action: Action::PatchReview { | 53 | action: Action::PatchReview { |
| 54 | verdict, | 54 | verdict, |
| 55 | body: body.to_string(), | 55 | body: body.to_string(), |
| 56 | revision, | 56 | revision: Some(revision), |
| 57 | }, | 57 | }, |
| 58 | clock: 0, | 58 | clock: 0, |
| 59 | } | 59 | } |
tests/sync_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -443,7 +443,7 @@ fn test_patch_review_across_repos() { | |||
| 443 | action: Action::PatchReview { | 443 | action: Action::PatchReview { |
| 444 | verdict: ReviewVerdict::Approve, | 444 | verdict: ReviewVerdict::Approve, |
| 445 | body: "LGTM!".to_string(), | 445 | body: "LGTM!".to_string(), |
| 446 | revision: 1, | 446 | revision: Some(1), |
| 447 | }, | 447 | }, |
| 448 | clock: 0, | 448 | clock: 0, |
| 449 | }; | 449 | }; |
| @@ -507,7 +507,7 @@ fn test_concurrent_review_and_revise() { | |||
| 507 | action: Action::PatchReview { | 507 | action: Action::PatchReview { |
| 508 | verdict: ReviewVerdict::RequestChanges, | 508 | verdict: ReviewVerdict::RequestChanges, |
| 509 | body: "Needs work".to_string(), | 509 | body: "Needs work".to_string(), |
| 510 | revision: 1, | 510 | revision: Some(1), |
| 511 | }, | 511 | }, |
| 512 | clock: 0, | 512 | clock: 0, |
| 513 | }; | 513 | }; |