Skip to main content

libgrite_core/
hash.rs

1use blake2::{Blake2b, Digest};
2use blake2::digest::consts::U32;
3use ciborium::Value;
4
5use crate::types::event::EventKind;
6use crate::types::ids::{ActorId, EventId, IssueId};
7
8/// Schema version for event hashing
9pub const SCHEMA_VERSION: u8 = 1;
10
11/// Compute the event_id from event fields using canonical CBOR + BLAKE2b-256
12pub fn compute_event_id(
13    issue_id: &IssueId,
14    actor: &ActorId,
15    ts_unix_ms: u64,
16    parent: Option<&EventId>,
17    kind: &EventKind,
18) -> EventId {
19    let preimage = build_canonical_cbor(issue_id, actor, ts_unix_ms, parent, kind);
20    let mut hasher = Blake2b::<U32>::new();
21    hasher.update(&preimage);
22    hasher.finalize().into()
23}
24
25/// Build the canonical CBOR preimage for hashing
26/// Format: [schema_version, issue_id, actor, ts_unix_ms, parent, kind_tag, kind_payload]
27pub fn build_canonical_cbor(
28    issue_id: &IssueId,
29    actor: &ActorId,
30    ts_unix_ms: u64,
31    parent: Option<&EventId>,
32    kind: &EventKind,
33) -> Vec<u8> {
34    let (kind_tag, kind_payload) = kind_to_tag_and_payload(kind);
35
36    let parent_value = match parent {
37        Some(p) => Value::Bytes(p.to_vec()),
38        None => Value::Null,
39    };
40
41    let array = Value::Array(vec![
42        Value::Integer(SCHEMA_VERSION.into()),
43        Value::Bytes(issue_id.to_vec()),
44        Value::Bytes(actor.to_vec()),
45        Value::Integer(ts_unix_ms.into()),
46        parent_value,
47        Value::Integer(kind_tag.into()),
48        kind_payload,
49    ]);
50
51    let mut buf = Vec::new();
52    ciborium::into_writer(&array, &mut buf).expect("CBOR serialization should not fail");
53    buf
54}
55
56/// Convert EventKind to (tag, payload) for CBOR encoding
57/// This is public so libgrit-git can use it for chunk encoding
58pub fn kind_to_tag_and_payload(kind: &EventKind) -> (u32, ciborium::Value) {
59    match kind {
60        EventKind::IssueCreated { title, body, labels } => {
61            // Labels must be sorted lexicographically for hashing
62            let mut sorted_labels = labels.clone();
63            sorted_labels.sort();
64            let labels_value = Value::Array(
65                sorted_labels.into_iter().map(Value::Text).collect()
66            );
67            (
68                1,
69                Value::Array(vec![
70                    Value::Text(title.clone()),
71                    Value::Text(body.clone()),
72                    labels_value,
73                ]),
74            )
75        }
76        EventKind::IssueUpdated { title, body } => {
77            let title_value = match title {
78                Some(t) => Value::Text(t.clone()),
79                None => Value::Null,
80            };
81            let body_value = match body {
82                Some(b) => Value::Text(b.clone()),
83                None => Value::Null,
84            };
85            (
86                2,
87                Value::Array(vec![title_value, body_value]),
88            )
89        }
90        EventKind::CommentAdded { body } => {
91            (
92                3,
93                Value::Array(vec![Value::Text(body.clone())]),
94            )
95        }
96        EventKind::LabelAdded { label } => {
97            (
98                4,
99                Value::Array(vec![Value::Text(label.clone())]),
100            )
101        }
102        EventKind::LabelRemoved { label } => {
103            (
104                5,
105                Value::Array(vec![Value::Text(label.clone())]),
106            )
107        }
108        EventKind::StateChanged { state } => {
109            (
110                6,
111                Value::Array(vec![Value::Text(state.as_str().to_string())]),
112            )
113        }
114        EventKind::LinkAdded { url, note } => {
115            let note_value = match note {
116                Some(n) => Value::Text(n.clone()),
117                None => Value::Null,
118            };
119            (
120                7,
121                Value::Array(vec![Value::Text(url.clone()), note_value]),
122            )
123        }
124        EventKind::AssigneeAdded { user } => {
125            (
126                8,
127                Value::Array(vec![Value::Text(user.clone())]),
128            )
129        }
130        EventKind::AssigneeRemoved { user } => {
131            (
132                9,
133                Value::Array(vec![Value::Text(user.clone())]),
134            )
135        }
136        EventKind::AttachmentAdded { name, sha256, mime } => {
137            (
138                10,
139                Value::Array(vec![
140                    Value::Text(name.clone()),
141                    Value::Bytes(sha256.to_vec()),
142                    Value::Text(mime.clone()),
143                ]),
144            )
145        }
146        EventKind::DependencyAdded { target, dep_type } => {
147            (
148                11,
149                Value::Array(vec![
150                    Value::Bytes(target.to_vec()),
151                    Value::Text(dep_type.as_str().to_string()),
152                ]),
153            )
154        }
155        EventKind::DependencyRemoved { target, dep_type } => {
156            (
157                12,
158                Value::Array(vec![
159                    Value::Bytes(target.to_vec()),
160                    Value::Text(dep_type.as_str().to_string()),
161                ]),
162            )
163        }
164        EventKind::ContextUpdated { path, language, symbols, summary, content_hash } => {
165            // Symbols sorted by (name, kind) for deterministic hashing
166            let mut sorted_symbols = symbols.clone();
167            sorted_symbols.sort_by(|a, b| (&a.name, &a.kind).cmp(&(&b.name, &b.kind)));
168            let symbols_value = Value::Array(
169                sorted_symbols.iter().map(|s| {
170                    Value::Array(vec![
171                        Value::Text(s.name.clone()),
172                        Value::Text(s.kind.clone()),
173                        Value::Integer(s.line_start.into()),
174                        Value::Integer(s.line_end.into()),
175                    ])
176                }).collect()
177            );
178            (
179                13,
180                Value::Array(vec![
181                    Value::Text(path.clone()),
182                    Value::Text(language.clone()),
183                    symbols_value,
184                    Value::Text(summary.clone()),
185                    Value::Bytes(content_hash.to_vec()),
186                ]),
187            )
188        }
189        EventKind::ProjectContextUpdated { key, value } => {
190            (
191                14,
192                Value::Array(vec![
193                    Value::Text(key.clone()),
194                    Value::Text(value.clone()),
195                ]),
196            )
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::types::event::IssueState;
205    use crate::types::ids::hex_to_id;
206
207    // Test vectors from docs/hash-vectors.md
208
209    #[test]
210    fn test_vector_1_issue_created() {
211        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
212        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
213        let ts_unix_ms: u64 = 1700000000000;
214        let parent: Option<&EventId> = None;
215        let kind = EventKind::IssueCreated {
216            title: "Test".to_string(),
217            body: "Body".to_string(),
218            labels: vec!["bug".to_string(), "p0".to_string()],
219        };
220
221        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
222        let expected_cbor = hex::decode(
223            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe56800f60183645465737464426f64798263627567627030"
224        ).unwrap();
225        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
226
227        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
228        let expected_event_id: EventId = hex_to_id(
229            "9c2aee7924bf7482dd3842c6ec32fd5103883b9d2354f63df2075ac61fe3d827"
230        ).unwrap();
231        assert_eq!(event_id, expected_event_id);
232    }
233
234    #[test]
235    fn test_vector_2_issue_updated() {
236        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
237        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
238        let ts_unix_ms: u64 = 1700000000000;
239        let parent: Option<&EventId> = None;
240        let kind = EventKind::IssueUpdated {
241            title: Some("Title 2".to_string()),
242            body: None,
243        };
244
245        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
246        let expected_cbor = hex::decode(
247            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe56800f60282675469746c652032f6"
248        ).unwrap();
249        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
250
251        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
252        let expected_event_id: EventId = hex_to_id(
253            "5227efec6ae3d41725827edb3e62d00a595784d7adec58fb4e1b787c44c4b333"
254        ).unwrap();
255        assert_eq!(event_id, expected_event_id);
256    }
257
258    #[test]
259    fn test_vector_3_comment_added() {
260        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
261        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
262        let ts_unix_ms: u64 = 1700000001000;
263        let parent_bytes: EventId = hex_to_id(
264            "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"
265        ).unwrap();
266        let parent = Some(&parent_bytes);
267        let kind = EventKind::CommentAdded {
268            body: "Looks good".to_string(),
269        };
270
271        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
272        let expected_cbor = hex::decode(
273            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe56be85820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f03816a4c6f6f6b7320676f6f64"
274        ).unwrap();
275        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
276
277        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
278        let expected_event_id: EventId = hex_to_id(
279            "fca597420160df9f7230b28384a27dc86656b206520e5c8085e78cbb02a46e27"
280        ).unwrap();
281        assert_eq!(event_id, expected_event_id);
282    }
283
284    #[test]
285    fn test_vector_4_label_added() {
286        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
287        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
288        let ts_unix_ms: u64 = 1700000002000;
289        let parent: Option<&EventId> = None;
290        let kind = EventKind::LabelAdded {
291            label: "bug".to_string(),
292        };
293
294        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
295        let expected_cbor = hex::decode(
296            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe56fd0f6048163627567"
297        ).unwrap();
298        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
299
300        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
301        let expected_event_id: EventId = hex_to_id(
302            "d742a0d9c83f17176e30511d62045686b491ddf55f8d1dfe7a74921787bdd436"
303        ).unwrap();
304        assert_eq!(event_id, expected_event_id);
305    }
306
307    #[test]
308    fn test_vector_5_label_removed() {
309        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
310        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
311        let ts_unix_ms: u64 = 1700000003000;
312        let parent: Option<&EventId> = None;
313        let kind = EventKind::LabelRemoved {
314            label: "wip".to_string(),
315        };
316
317        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
318        let expected_cbor = hex::decode(
319            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe573b8f6058163776970"
320        ).unwrap();
321        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
322
323        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
324        let expected_event_id: EventId = hex_to_id(
325            "f23e9c69c3fa4cd2889e57fe1c547630afa132052197a5fe449e6d5acf22c40c"
326        ).unwrap();
327        assert_eq!(event_id, expected_event_id);
328    }
329
330    #[test]
331    fn test_vector_6_state_changed() {
332        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
333        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
334        let ts_unix_ms: u64 = 1700000004000;
335        let parent: Option<&EventId> = None;
336        let kind = EventKind::StateChanged {
337            state: IssueState::Closed,
338        };
339
340        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
341        let expected_cbor = hex::decode(
342            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe577a0f6068166636c6f736564"
343        ).unwrap();
344        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
345
346        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
347        let expected_event_id: EventId = hex_to_id(
348            "839ae6d0898f48efcc7a41fdbb9631e64ba1f05a6c1725fc196971bfd1645b2b"
349        ).unwrap();
350        assert_eq!(event_id, expected_event_id);
351    }
352
353    #[test]
354    fn test_vector_7_link_added() {
355        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
356        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
357        let ts_unix_ms: u64 = 1700000005000;
358        let parent: Option<&EventId> = None;
359        let kind = EventKind::LinkAdded {
360            url: "https://example.com".to_string(),
361            note: Some("ref".to_string()),
362        };
363
364        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
365        let expected_cbor = hex::decode(
366            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe57b88f607827368747470733a2f2f6578616d706c652e636f6d63726566"
367        ).unwrap();
368        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
369
370        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
371        let expected_event_id: EventId = hex_to_id(
372            "b8af76be8b7a40244bb8e731130ed52969a77b87532dadf9a00a352eeb00e3b5"
373        ).unwrap();
374        assert_eq!(event_id, expected_event_id);
375    }
376
377    #[test]
378    fn test_vector_8_assignee_added() {
379        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
380        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
381        let ts_unix_ms: u64 = 1700000006000;
382        let parent: Option<&EventId> = None;
383        let kind = EventKind::AssigneeAdded {
384            user: "alice".to_string(),
385        };
386
387        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
388        let expected_cbor = hex::decode(
389            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe57f70f6088165616c696365"
390        ).unwrap();
391        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
392
393        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
394        let expected_event_id: EventId = hex_to_id(
395            "42f329d826d34d425dd67080d91f6c909bc56411c9add54389fbec5d457b14e4"
396        ).unwrap();
397        assert_eq!(event_id, expected_event_id);
398    }
399
400    #[test]
401    fn test_vector_9_assignee_removed() {
402        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
403        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
404        let ts_unix_ms: u64 = 1700000007000;
405        let parent: Option<&EventId> = None;
406        let kind = EventKind::AssigneeRemoved {
407            user: "alice".to_string(),
408        };
409
410        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
411        let expected_cbor = hex::decode(
412            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe58358f6098165616c696365"
413        ).unwrap();
414        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
415
416        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
417        let expected_event_id: EventId = hex_to_id(
418            "bfb0fdfed0f0ee36f31107963317dd904143f37d9ef8792f64272cf2f07f6a1e"
419        ).unwrap();
420        assert_eq!(event_id, expected_event_id);
421    }
422
423    #[test]
424    fn test_vector_10_attachment_added() {
425        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
426        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
427        let ts_unix_ms: u64 = 1700000008000;
428        let parent: Option<&EventId> = None;
429        let sha256: [u8; 32] = hex_to_id(
430            "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
431        ).unwrap();
432        let kind = EventKind::AttachmentAdded {
433            name: "log.txt".to_string(),
434            sha256,
435            mime: "text/plain".to_string(),
436        };
437
438        let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
439        let expected_cbor = hex::decode(
440            "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe58740f60a83676c6f672e7478745820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f6a746578742f706c61696e"
441        ).unwrap();
442        assert_eq!(hex::encode(&cbor), hex::encode(&expected_cbor), "CBOR mismatch");
443
444        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
445        let expected_event_id: EventId = hex_to_id(
446            "dc83946d33437f0b73d8b04c63f7b0b85b9e9a24e790fee3ca129d3d8b870749"
447        ).unwrap();
448        assert_eq!(event_id, expected_event_id);
449    }
450
451    #[test]
452    fn test_vector_11_dependency_added() {
453        use crate::types::event::DependencyType;
454        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
455        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
456        let ts_unix_ms: u64 = 1700000009000;
457        let target: IssueId = hex_to_id("aabbccddeeff00112233445566778899").unwrap();
458        let kind = EventKind::DependencyAdded {
459            target,
460            dep_type: DependencyType::Blocks,
461        };
462
463        // Verify deterministic hashing
464        let id1 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
465        let id2 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
466        assert_eq!(id1, id2);
467
468        // Different dep_type produces different hash
469        let kind2 = EventKind::DependencyAdded {
470            target,
471            dep_type: DependencyType::DependsOn,
472        };
473        let id3 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind2);
474        assert_ne!(id1, id3);
475    }
476
477    #[test]
478    fn test_vector_12_dependency_removed() {
479        use crate::types::event::DependencyType;
480        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
481        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
482        let ts_unix_ms: u64 = 1700000010000;
483        let target: IssueId = hex_to_id("aabbccddeeff00112233445566778899").unwrap();
484        let kind = EventKind::DependencyRemoved {
485            target,
486            dep_type: DependencyType::Blocks,
487        };
488
489        let id1 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
490        let id2 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
491        assert_eq!(id1, id2);
492
493        // DependencyAdded and DependencyRemoved with same fields produce different hashes
494        let kind_add = EventKind::DependencyAdded {
495            target,
496            dep_type: DependencyType::Blocks,
497        };
498        let id_add = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind_add);
499        assert_ne!(id1, id_add);
500    }
501
502    #[test]
503    fn test_vector_13_context_updated() {
504        use crate::types::event::SymbolInfo;
505        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
506        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
507        let ts_unix_ms: u64 = 1700000011000;
508        let kind = EventKind::ContextUpdated {
509            path: "src/main.rs".to_string(),
510            language: "rust".to_string(),
511            symbols: vec![
512                SymbolInfo { name: "main".to_string(), kind: "function".to_string(), line_start: 1, line_end: 10 },
513                SymbolInfo { name: "Config".to_string(), kind: "struct".to_string(), line_start: 12, line_end: 20 },
514            ],
515            summary: "Entry point".to_string(),
516            content_hash: [0xAA; 32],
517        };
518
519        let id1 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
520        let id2 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
521        assert_eq!(id1, id2);
522
523        // Symbol order shouldn't matter (sorted during hashing)
524        let kind_reordered = EventKind::ContextUpdated {
525            path: "src/main.rs".to_string(),
526            language: "rust".to_string(),
527            symbols: vec![
528                SymbolInfo { name: "Config".to_string(), kind: "struct".to_string(), line_start: 12, line_end: 20 },
529                SymbolInfo { name: "main".to_string(), kind: "function".to_string(), line_start: 1, line_end: 10 },
530            ],
531            summary: "Entry point".to_string(),
532            content_hash: [0xAA; 32],
533        };
534        let id3 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind_reordered);
535        assert_eq!(id1, id3, "Symbol order should not affect hash");
536    }
537
538    #[test]
539    fn test_vector_14_project_context_updated() {
540        let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
541        let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
542        let ts_unix_ms: u64 = 1700000012000;
543        let kind = EventKind::ProjectContextUpdated {
544            key: "framework".to_string(),
545            value: "actix-web".to_string(),
546        };
547
548        let id1 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
549        let id2 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
550        assert_eq!(id1, id2);
551
552        // Different key produces different hash
553        let kind2 = EventKind::ProjectContextUpdated {
554            key: "build_system".to_string(),
555            value: "actix-web".to_string(),
556        };
557        let id3 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind2);
558        assert_ne!(id1, id3);
559    }
560}