fix(events): Handle nullable label and milestone in resource events

GitLab returns null for the label/milestone fields on resource_label_events
and resource_milestone_events when the referenced label or milestone has
been deleted. This caused deserialization failures during sync.

- Add migration 012 to recreate both event tables with nullable
  label_name, milestone_title, and milestone_id columns (SQLite
  requires table recreation to alter NOT NULL constraints)
- Change GitLabLabelEvent.label and GitLabMilestoneEvent.milestone
  to Option<> in the Rust types
- Update upsert functions to pass through None values correctly
- Add tests for null label and null milestone deserialization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-03 17:36:17 -05:00
parent deafa88af5
commit a92e176bb6
5 changed files with 134 additions and 14 deletions

View File

@@ -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');

View File

@@ -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.

View File

@@ -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,

View File

@@ -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<GitLabLabelRef>,
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<GitLabMilestoneRef>,
pub action: String,
}

View File

@@ -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]