Skip to main content

libgrite_core/
hash.rs

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