//! Issue transformer: converts GitLabIssue to local schema. use chrono::DateTime; use thiserror::Error; use crate::gitlab::types::GitLabIssue; #[derive(Debug, Error)] pub enum TransformError { #[error("Failed to parse timestamp '{0}': {1}")] TimestampParse(String, String), } /// Local schema representation of an issue row. #[derive(Debug, Clone)] pub struct IssueRow { pub gitlab_id: i64, pub iid: i64, pub project_id: i64, pub title: String, pub description: Option, pub state: String, pub author_username: String, pub created_at: i64, // ms epoch UTC pub updated_at: i64, // ms epoch UTC pub web_url: String, pub due_date: Option, // YYYY-MM-DD pub milestone_title: Option, // Denormalized for quick display } /// Local schema representation of a milestone row. #[derive(Debug, Clone)] pub struct MilestoneRow { pub gitlab_id: i64, pub project_id: i64, pub iid: i64, pub title: String, pub description: Option, pub state: Option, pub due_date: Option, pub web_url: Option, } /// Issue bundled with extracted metadata. #[derive(Debug, Clone)] pub struct IssueWithMetadata { pub issue: IssueRow, pub label_names: Vec, pub assignee_usernames: Vec, pub milestone: Option, } /// Parse ISO 8601 timestamp to milliseconds since Unix epoch. fn parse_timestamp(ts: &str) -> Result { DateTime::parse_from_rfc3339(ts) .map(|dt| dt.timestamp_millis()) .map_err(|e| TransformError::TimestampParse(ts.to_string(), e.to_string())) } /// Transform a GitLab issue into local schema format. pub fn transform_issue(issue: GitLabIssue) -> Result { let created_at = parse_timestamp(&issue.created_at)?; let updated_at = parse_timestamp(&issue.updated_at)?; let assignee_usernames: Vec = issue.assignees.iter().map(|a| a.username.clone()).collect(); let milestone_title = issue.milestone.as_ref().map(|m| m.title.clone()); let milestone = issue.milestone.as_ref().map(|m| MilestoneRow { gitlab_id: m.id, project_id: m.project_id.unwrap_or(issue.project_id), iid: m.iid, title: m.title.clone(), description: m.description.clone(), state: m.state.clone(), due_date: m.due_date.clone(), web_url: m.web_url.clone(), }); Ok(IssueWithMetadata { issue: IssueRow { gitlab_id: issue.id, iid: issue.iid, project_id: issue.project_id, title: issue.title, description: issue.description, state: issue.state, author_username: issue.author.username, created_at, updated_at, web_url: issue.web_url, due_date: issue.due_date, milestone_title, }, label_names: issue.labels, assignee_usernames, milestone, }) } #[cfg(test)] mod tests { use super::*; use crate::gitlab::types::{GitLabAuthor, GitLabMilestone}; fn make_test_issue() -> GitLabIssue { GitLabIssue { id: 12345, iid: 42, project_id: 100, title: "Test issue".to_string(), description: Some("Description here".to_string()), state: "opened".to_string(), created_at: "2024-01-15T10:00:00.000Z".to_string(), updated_at: "2024-01-20T15:30:00.000Z".to_string(), closed_at: None, author: GitLabAuthor { id: 1, username: "testuser".to_string(), name: "Test User".to_string(), }, assignees: vec![], labels: vec!["bug".to_string(), "priority::high".to_string()], milestone: None, due_date: None, web_url: "https://gitlab.example.com/group/project/-/issues/42".to_string(), } } #[test] fn transforms_issue_with_all_fields() { let issue = make_test_issue(); let result = transform_issue(issue).unwrap(); assert_eq!(result.issue.gitlab_id, 12345); assert_eq!(result.issue.iid, 42); assert_eq!(result.issue.project_id, 100); assert_eq!(result.issue.title, "Test issue"); assert_eq!( result.issue.description, Some("Description here".to_string()) ); assert_eq!(result.issue.state, "opened"); assert_eq!(result.issue.author_username, "testuser"); assert_eq!( result.issue.web_url, "https://gitlab.example.com/group/project/-/issues/42" ); } #[test] fn handles_missing_description() { let mut issue = make_test_issue(); issue.description = None; let result = transform_issue(issue).unwrap(); assert!(result.issue.description.is_none()); } #[test] fn extracts_label_names() { let issue = make_test_issue(); let result = transform_issue(issue).unwrap(); assert_eq!(result.label_names.len(), 2); assert_eq!(result.label_names[0], "bug"); assert_eq!(result.label_names[1], "priority::high"); } #[test] fn handles_empty_labels() { let mut issue = make_test_issue(); issue.labels = vec![]; let result = transform_issue(issue).unwrap(); assert!(result.label_names.is_empty()); } #[test] fn parses_timestamps_to_ms_epoch() { let issue = make_test_issue(); let result = transform_issue(issue).unwrap(); // 2024-01-15T10:00:00.000Z = 1705312800000 ms assert_eq!(result.issue.created_at, 1705312800000); // 2024-01-20T15:30:00.000Z = 1705764600000 ms assert_eq!(result.issue.updated_at, 1705764600000); } #[test] fn handles_timezone_offset_timestamps() { let mut issue = make_test_issue(); // GitLab can return timestamps with timezone offset issue.created_at = "2024-01-15T05:00:00-05:00".to_string(); let result = transform_issue(issue).unwrap(); // 05:00 EST = 10:00 UTC = same as original test assert_eq!(result.issue.created_at, 1705312800000); } #[test] fn extracts_assignee_usernames() { let mut issue = make_test_issue(); issue.assignees = vec![ GitLabAuthor { id: 2, username: "alice".to_string(), name: "Alice".to_string(), }, GitLabAuthor { id: 3, username: "bob".to_string(), name: "Bob".to_string(), }, ]; let result = transform_issue(issue).unwrap(); assert_eq!(result.assignee_usernames.len(), 2); assert_eq!(result.assignee_usernames[0], "alice"); assert_eq!(result.assignee_usernames[1], "bob"); } #[test] fn extracts_milestone_info() { let mut issue = make_test_issue(); issue.milestone = Some(GitLabMilestone { id: 500, iid: 5, project_id: Some(100), title: "v1.0".to_string(), description: Some("First release".to_string()), state: Some("active".to_string()), due_date: Some("2024-02-01".to_string()), web_url: Some("https://gitlab.example.com/-/milestones/5".to_string()), }); let result = transform_issue(issue).unwrap(); // Denormalized title on issue for quick display assert_eq!(result.issue.milestone_title, Some("v1.0".to_string())); // Full milestone row for normalized storage let milestone = result.milestone.expect("should have milestone"); assert_eq!(milestone.gitlab_id, 500); assert_eq!(milestone.iid, 5); assert_eq!(milestone.project_id, 100); assert_eq!(milestone.title, "v1.0"); assert_eq!(milestone.description, Some("First release".to_string())); assert_eq!(milestone.state, Some("active".to_string())); assert_eq!(milestone.due_date, Some("2024-02-01".to_string())); assert_eq!( milestone.web_url, Some("https://gitlab.example.com/-/milestones/5".to_string()) ); } #[test] fn handles_missing_milestone() { let issue = make_test_issue(); let result = transform_issue(issue).unwrap(); assert!(result.issue.milestone_title.is_none()); assert!(result.milestone.is_none()); } #[test] fn extracts_due_date() { let mut issue = make_test_issue(); issue.due_date = Some("2024-02-15".to_string()); let result = transform_issue(issue).unwrap(); assert_eq!(result.issue.due_date, Some("2024-02-15".to_string())); } }