feat(events): Implement Gate 1 resource events infrastructure

Add complete infrastructure for ingesting GitLab Resource Events
(state, label, milestone) into local SQLite tables. This enables
temporal queries (timeline, file-history, trace) in later gates.

- Add migration 011: resource_state/label/milestone_events tables,
  entity_references table, pending_dependent_fetches queue
- Add 6 serde types for GitLab Resource Events API responses
- Add fetchResourceEvents config flag with --no-events CLI override
- Add fetch_all_pages<T> generic paginator and 6 API endpoint methods
- Add DB upsert functions with savepoint atomicity (events_db.rs)
- Add dependent fetch queue with exponential backoff (dependent_queue.rs)
- Add 'lore count events' command with human table and robot JSON output
- Extend 'lore stats --check' with event FK integrity and queue health
- Add 8 unit tests for resource event type deserialization

Closes: bd-hu3, bd-2e8, bd-2fm, bd-sqw, bd-1uc, bd-tir, bd-3sh, bd-1m8

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-03 11:23:44 -05:00
parent 549a0646d7
commit 98907ac666
18 changed files with 1227 additions and 21 deletions

View File

@@ -1,8 +1,10 @@
//! Tests for GitLab API response type deserialization.
use lore::gitlab::types::{
GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabMergeRequest, GitLabMilestone, GitLabNote,
GitLabNotePosition, GitLabReferences, GitLabReviewer,
GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabLabelEvent, GitLabLabelRef,
GitLabMergeRequest, GitLabMergeRequestRef, GitLabMilestone, GitLabMilestoneEvent,
GitLabMilestoneRef, GitLabNote, GitLabNotePosition, GitLabReferences, GitLabReviewer,
GitLabStateEvent,
};
#[test]
@@ -637,3 +639,209 @@ fn deserializes_diffnote_position_with_line_range() {
assert_eq!(range.start_line(), Some(10));
assert_eq!(range.end_line(), Some(15));
}
// === Resource Event type tests ===
#[test]
fn deserializes_state_event_closed_by_mr() {
let json = r#"{
"id": 1001,
"user": {
"id": 42,
"username": "developer",
"name": "Dev User"
},
"created_at": "2024-03-15T10:30:00.000Z",
"resource_type": "Issue",
"resource_id": 555,
"state": "closed",
"source_commit": null,
"source_merge_request": {
"iid": 99,
"title": "Fix the bug",
"web_url": "https://gitlab.example.com/group/project/-/merge_requests/99"
}
}"#;
let event: GitLabStateEvent =
serde_json::from_str(json).expect("Failed to deserialize state event");
assert_eq!(event.id, 1001);
assert!(event.user.is_some());
assert_eq!(event.user.as_ref().unwrap().username, "developer");
assert_eq!(event.resource_type, "Issue");
assert_eq!(event.resource_id, 555);
assert_eq!(event.state, "closed");
assert!(event.source_commit.is_none());
assert!(event.source_merge_request.is_some());
let mr_ref = event.source_merge_request.unwrap();
assert_eq!(mr_ref.iid, 99);
assert_eq!(mr_ref.title, Some("Fix the bug".to_string()));
}
#[test]
fn deserializes_state_event_simple_no_user() {
let json = r#"{
"id": 1002,
"user": null,
"created_at": "2024-03-15T10:30:00.000Z",
"resource_type": "MergeRequest",
"resource_id": 777,
"state": "merged",
"source_commit": "abc123def456",
"source_merge_request": null
}"#;
let event: GitLabStateEvent =
serde_json::from_str(json).expect("Failed to deserialize state event without user");
assert_eq!(event.id, 1002);
assert!(event.user.is_none());
assert_eq!(event.resource_type, "MergeRequest");
assert_eq!(event.state, "merged");
assert_eq!(event.source_commit, Some("abc123def456".to_string()));
assert!(event.source_merge_request.is_none());
}
#[test]
fn deserializes_label_event_add() {
let json = r##"{
"id": 2001,
"user": {
"id": 42,
"username": "developer",
"name": "Dev User"
},
"created_at": "2024-03-15T10:30:00.000Z",
"resource_type": "Issue",
"resource_id": 555,
"label": {
"id": 100,
"name": "bug",
"color": "#FF0000",
"description": "Bug label"
},
"action": "add"
}"##;
let event: GitLabLabelEvent =
serde_json::from_str(json).expect("Failed to deserialize label event");
assert_eq!(event.id, 2001);
assert_eq!(event.action, "add");
assert_eq!(event.label.id, 100);
assert_eq!(event.label.name, "bug");
assert_eq!(event.label.color, Some("#FF0000".to_string()));
assert_eq!(event.label.description, Some("Bug label".to_string()));
}
#[test]
fn deserializes_label_event_remove_null_color() {
let json = r#"{
"id": 2002,
"user": {
"id": 42,
"username": "developer",
"name": "Dev User"
},
"created_at": "2024-03-15T10:30:00.000Z",
"resource_type": "MergeRequest",
"resource_id": 777,
"label": {
"id": 101,
"name": "needs-review",
"color": null,
"description": null
},
"action": "remove"
}"#;
let event: GitLabLabelEvent =
serde_json::from_str(json).expect("Failed to deserialize label remove event");
assert_eq!(event.action, "remove");
assert!(event.label.color.is_none());
assert!(event.label.description.is_none());
}
#[test]
fn deserializes_milestone_event() {
let json = r#"{
"id": 3001,
"user": {
"id": 42,
"username": "developer",
"name": "Dev User"
},
"created_at": "2024-03-15T10:30:00.000Z",
"resource_type": "Issue",
"resource_id": 555,
"milestone": {
"id": 200,
"iid": 5,
"title": "v1.0"
},
"action": "add"
}"#;
let event: GitLabMilestoneEvent =
serde_json::from_str(json).expect("Failed to deserialize milestone event");
assert_eq!(event.id, 3001);
assert_eq!(event.action, "add");
assert_eq!(event.milestone.id, 200);
assert_eq!(event.milestone.iid, 5);
assert_eq!(event.milestone.title, "v1.0");
}
#[test]
fn deserializes_merge_request_ref() {
let json = r#"{
"iid": 42,
"title": "Feature branch",
"web_url": "https://gitlab.example.com/group/project/-/merge_requests/42"
}"#;
let mr_ref: GitLabMergeRequestRef =
serde_json::from_str(json).expect("Failed to deserialize MR ref");
assert_eq!(mr_ref.iid, 42);
assert_eq!(mr_ref.title, Some("Feature branch".to_string()));
assert_eq!(
mr_ref.web_url,
Some("https://gitlab.example.com/group/project/-/merge_requests/42".to_string())
);
}
#[test]
fn deserializes_label_ref() {
let json = r##"{
"id": 100,
"name": "bug",
"color": "#FF0000",
"description": "Bug label"
}"##;
let label_ref: GitLabLabelRef =
serde_json::from_str(json).expect("Failed to deserialize label ref");
assert_eq!(label_ref.id, 100);
assert_eq!(label_ref.name, "bug");
assert_eq!(label_ref.color, Some("#FF0000".to_string()));
}
#[test]
fn deserializes_milestone_ref() {
let json = r#"{
"id": 200,
"iid": 5,
"title": "v1.0"
}"#;
let ms_ref: GitLabMilestoneRef =
serde_json::from_str(json).expect("Failed to deserialize milestone ref");
assert_eq!(ms_ref.id, 200);
assert_eq!(ms_ref.iid, 5);
assert_eq!(ms_ref.title, "v1.0");
}