diff --git a/migrations/012_nullable_label_milestone.sql b/migrations/012_nullable_label_milestone.sql new file mode 100644 index 0000000..7ff316f --- /dev/null +++ b/migrations/012_nullable_label_milestone.sql @@ -0,0 +1,65 @@ +-- Migration 012: Make label_name and milestone_title nullable +-- GitLab returns null for these when the referenced label/milestone has been deleted. + +-- Recreate resource_label_events with nullable label_name +CREATE TABLE resource_label_events_new ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER NOT NULL, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE, + merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE, + action TEXT NOT NULL CHECK (action IN ('add', 'remove')), + label_name TEXT, + actor_gitlab_id INTEGER, + actor_username TEXT, + created_at INTEGER NOT NULL, + CHECK ( + (issue_id IS NOT NULL AND merge_request_id IS NULL) OR + (issue_id IS NULL AND merge_request_id IS NOT NULL) + ) +); + +INSERT INTO resource_label_events_new +SELECT * FROM resource_label_events; + +DROP TABLE resource_label_events; +ALTER TABLE resource_label_events_new RENAME TO resource_label_events; + +CREATE UNIQUE INDEX uq_label_events_gitlab ON resource_label_events(gitlab_id, project_id); +CREATE INDEX idx_label_events_issue ON resource_label_events(issue_id) WHERE issue_id IS NOT NULL; +CREATE INDEX idx_label_events_mr ON resource_label_events(merge_request_id) WHERE merge_request_id IS NOT NULL; +CREATE INDEX idx_label_events_created ON resource_label_events(created_at); + +-- Recreate resource_milestone_events with nullable milestone_title +CREATE TABLE resource_milestone_events_new ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER NOT NULL, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE, + merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE, + action TEXT NOT NULL CHECK (action IN ('add', 'remove')), + milestone_title TEXT, + milestone_id INTEGER, + actor_gitlab_id INTEGER, + actor_username TEXT, + created_at INTEGER NOT NULL, + CHECK ( + (issue_id IS NOT NULL AND merge_request_id IS NULL) OR + (issue_id IS NULL AND merge_request_id IS NOT NULL) + ) +); + +INSERT INTO resource_milestone_events_new +SELECT * FROM resource_milestone_events; + +DROP TABLE resource_milestone_events; +ALTER TABLE resource_milestone_events_new RENAME TO resource_milestone_events; + +CREATE UNIQUE INDEX uq_milestone_events_gitlab ON resource_milestone_events(gitlab_id, project_id); +CREATE INDEX idx_milestone_events_issue ON resource_milestone_events(issue_id) WHERE issue_id IS NOT NULL; +CREATE INDEX idx_milestone_events_mr ON resource_milestone_events(merge_request_id) WHERE merge_request_id IS NOT NULL; +CREATE INDEX idx_milestone_events_created ON resource_milestone_events(created_at); + +-- Update schema version +INSERT INTO schema_version (version, applied_at, description) +VALUES (12, strftime('%s', 'now') * 1000, 'Make label_name and milestone_title nullable for deleted labels/milestones'); diff --git a/src/core/db.rs b/src/core/db.rs index bee0fa4..05af814 100644 --- a/src/core/db.rs +++ b/src/core/db.rs @@ -39,6 +39,10 @@ const MIGRATIONS: &[(&str, &str)] = &[ "011", include_str!("../../migrations/011_resource_events.sql"), ), + ( + "012", + include_str!("../../migrations/012_nullable_label_milestone.sql"), + ), ]; /// Create a database connection with production-grade pragmas. diff --git a/src/core/events_db.rs b/src/core/events_db.rs index 5409a1d..7bf6480 100644 --- a/src/core/events_db.rs +++ b/src/core/events_db.rs @@ -82,7 +82,7 @@ pub fn upsert_label_events( issue_id, merge_request_id, event.action, - event.label.name, + event.label.as_ref().map(|l| l.name.as_str()), actor_id, actor_username, created_at, @@ -123,8 +123,8 @@ pub fn upsert_milestone_events( issue_id, merge_request_id, event.action, - event.milestone.title, - event.milestone.id, + event.milestone.as_ref().map(|m| m.title.as_str()), + event.milestone.as_ref().map(|m| m.id), actor_id, actor_username, created_at, diff --git a/src/gitlab/types.rs b/src/gitlab/types.rs index af5c8a3..b256029 100644 --- a/src/gitlab/types.rs +++ b/src/gitlab/types.rs @@ -230,7 +230,7 @@ pub struct GitLabLabelEvent { pub created_at: String, pub resource_type: String, pub resource_id: i64, - pub label: GitLabLabelRef, + pub label: Option, pub action: String, } @@ -242,7 +242,7 @@ pub struct GitLabMilestoneEvent { pub created_at: String, pub resource_type: String, pub resource_id: i64, - pub milestone: GitLabMilestoneRef, + pub milestone: Option, pub action: String, } diff --git a/tests/gitlab_types_tests.rs b/tests/gitlab_types_tests.rs index 103e031..113b187 100644 --- a/tests/gitlab_types_tests.rs +++ b/tests/gitlab_types_tests.rs @@ -729,10 +729,11 @@ fn deserializes_label_event_add() { 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())); + let label = event.label.expect("label should be Some"); + assert_eq!(label.id, 100); + assert_eq!(label.name, "bug"); + assert_eq!(label.color, Some("#FF0000".to_string())); + assert_eq!(label.description, Some("Bug label".to_string())); } #[test] @@ -760,8 +761,57 @@ fn deserializes_label_event_remove_null_color() { 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()); + let label = event.label.expect("label should be Some"); + assert!(label.color.is_none()); + assert!(label.description.is_none()); +} + +#[test] +fn deserializes_label_event_null_label() { + let json = r#"{ + "id": 2003, + "user": { + "id": 42, + "username": "developer", + "name": "Dev User" + }, + "created_at": "2024-03-15T10:30:00.000Z", + "resource_type": "Issue", + "resource_id": 999, + "label": null, + "action": "remove" + }"#; + + let event: GitLabLabelEvent = + serde_json::from_str(json).expect("Failed to deserialize label event with null label"); + + assert_eq!(event.id, 2003); + assert_eq!(event.action, "remove"); + assert!(event.label.is_none()); +} + +#[test] +fn deserializes_milestone_event_null_milestone() { + let json = r#"{ + "id": 3002, + "user": { + "id": 42, + "username": "developer", + "name": "Dev User" + }, + "created_at": "2024-03-15T10:30:00.000Z", + "resource_type": "Issue", + "resource_id": 999, + "milestone": null, + "action": "remove" + }"#; + + let event: GitLabMilestoneEvent = serde_json::from_str(json) + .expect("Failed to deserialize milestone event with null milestone"); + + assert_eq!(event.id, 3002); + assert_eq!(event.action, "remove"); + assert!(event.milestone.is_none()); } #[test] @@ -789,9 +839,10 @@ fn deserializes_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"); + let milestone = event.milestone.expect("milestone should be Some"); + assert_eq!(milestone.id, 200); + assert_eq!(milestone.iid, 5); + assert_eq!(milestone.title, "v1.0"); } #[test]