Introduces NormalizedMergeRequest transformer and updates discussion
normalization to handle both issue and MR discussions polymorphically.
New transformers:
- NormalizedMergeRequest: Transforms API MergeRequest to database row,
extracting labels/assignees/reviewers into separate collections for
junction table insertion. Handles draft detection, detailed_merge_status
preference over deprecated merge_status, and merge_user over merged_by.
Discussion transformer updates:
- NormalizedDiscussion now takes noteable_type ("Issue" | "MergeRequest")
and noteable_id for polymorphic FK binding
- normalize_discussions_for_issue(): Convenience wrapper for issues
- normalize_discussions_for_mr(): Convenience wrapper for MRs
- DiffNote position fields (type, line_range, SHA triplet) now extracted
from API position object for code review context
Design decisions:
- Transformer returns (normalized_item, labels, assignees, reviewers)
tuple for efficient batch insertion without re-querying
- Timestamps converted to ms epoch for SQLite storage consistency
- Optional fields use map() chains for clean null handling
The polymorphic discussion approach allows reusing the same discussions
and notes tables for both issues and MRs, with noteable_type + FK
determining the parent relationship.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
276 lines
8.7 KiB
Rust
276 lines
8.7 KiB
Rust
//! 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<String>,
|
|
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<String>, // YYYY-MM-DD
|
|
pub milestone_title: Option<String>, // 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<String>,
|
|
pub state: Option<String>,
|
|
pub due_date: Option<String>,
|
|
pub web_url: Option<String>,
|
|
}
|
|
|
|
/// Issue bundled with extracted metadata.
|
|
#[derive(Debug, Clone)]
|
|
pub struct IssueWithMetadata {
|
|
pub issue: IssueRow,
|
|
pub label_names: Vec<String>,
|
|
pub assignee_usernames: Vec<String>,
|
|
pub milestone: Option<MilestoneRow>,
|
|
}
|
|
|
|
/// Parse ISO 8601 timestamp to milliseconds since Unix epoch.
|
|
fn parse_timestamp(ts: &str) -> Result<i64, TransformError> {
|
|
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<IssueWithMetadata, TransformError> {
|
|
let created_at = parse_timestamp(&issue.created_at)?;
|
|
let updated_at = parse_timestamp(&issue.updated_at)?;
|
|
|
|
let assignee_usernames: Vec<String> =
|
|
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()));
|
|
}
|
|
}
|