feat(transformers): Add MR transformer and polymorphic discussion support
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>
This commit is contained in:
@@ -46,6 +46,18 @@ pub struct NormalizedNote {
|
||||
pub resolved: bool,
|
||||
pub resolved_by: Option<String>,
|
||||
pub resolved_at: Option<i64>,
|
||||
// DiffNote position fields (CP1 - basic path/line)
|
||||
pub position_old_path: Option<String>,
|
||||
pub position_new_path: Option<String>,
|
||||
pub position_old_line: Option<i32>,
|
||||
pub position_new_line: Option<i32>,
|
||||
// DiffNote extended position fields (CP2)
|
||||
pub position_type: Option<String>, // "text" | "image" | "file"
|
||||
pub position_line_range_start: Option<i32>, // multi-line comment start
|
||||
pub position_line_range_end: Option<i32>, // multi-line comment end
|
||||
pub position_base_sha: Option<String>, // Base commit SHA for diff
|
||||
pub position_start_sha: Option<String>, // Start commit SHA for diff
|
||||
pub position_head_sha: Option<String>, // Head commit SHA for diff
|
||||
}
|
||||
|
||||
/// Parse ISO 8601 timestamp to milliseconds, returning None on failure.
|
||||
@@ -113,6 +125,20 @@ pub fn transform_discussion(
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform a GitLab discussion for MR context.
|
||||
/// Convenience wrapper that uses NoteableRef::MergeRequest internally.
|
||||
pub fn transform_mr_discussion(
|
||||
gitlab_discussion: &GitLabDiscussion,
|
||||
local_project_id: i64,
|
||||
local_mr_id: i64,
|
||||
) -> NormalizedDiscussion {
|
||||
transform_discussion(
|
||||
gitlab_discussion,
|
||||
local_project_id,
|
||||
NoteableRef::MergeRequest(local_mr_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Transform notes from a GitLab discussion into normalized schema.
|
||||
pub fn transform_notes(
|
||||
gitlab_discussion: &GitLabDiscussion,
|
||||
@@ -134,6 +160,20 @@ fn transform_single_note(
|
||||
position: i32,
|
||||
now: i64,
|
||||
) -> NormalizedNote {
|
||||
// Extract DiffNote position fields if present
|
||||
let (
|
||||
position_old_path,
|
||||
position_new_path,
|
||||
position_old_line,
|
||||
position_new_line,
|
||||
position_type,
|
||||
position_line_range_start,
|
||||
position_line_range_end,
|
||||
position_base_sha,
|
||||
position_start_sha,
|
||||
position_head_sha,
|
||||
) = extract_position_fields(¬e.position);
|
||||
|
||||
NormalizedNote {
|
||||
gitlab_id: note.id,
|
||||
project_id: local_project_id,
|
||||
@@ -152,9 +192,138 @@ fn transform_single_note(
|
||||
.resolved_at
|
||||
.as_ref()
|
||||
.and_then(|ts| parse_timestamp_opt(ts)),
|
||||
position_old_path,
|
||||
position_new_path,
|
||||
position_old_line,
|
||||
position_new_line,
|
||||
position_type,
|
||||
position_line_range_start,
|
||||
position_line_range_end,
|
||||
position_base_sha,
|
||||
position_start_sha,
|
||||
position_head_sha,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract DiffNote position fields from GitLabNotePosition.
|
||||
/// Returns tuple of all position fields (all None if position is None).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn extract_position_fields(
|
||||
position: &Option<crate::gitlab::types::GitLabNotePosition>,
|
||||
) -> (
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<String>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
match position {
|
||||
Some(pos) => {
|
||||
let line_range_start = pos.line_range.as_ref().and_then(|lr| lr.start_line());
|
||||
let line_range_end = pos.line_range.as_ref().and_then(|lr| lr.end_line());
|
||||
|
||||
(
|
||||
pos.old_path.clone(),
|
||||
pos.new_path.clone(),
|
||||
pos.old_line,
|
||||
pos.new_line,
|
||||
pos.position_type.clone(),
|
||||
line_range_start,
|
||||
line_range_end,
|
||||
pos.base_sha.clone(),
|
||||
pos.start_sha.clone(),
|
||||
pos.head_sha.clone(),
|
||||
)
|
||||
}
|
||||
None => (None, None, None, None, None, None, None, None, None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse ISO 8601 timestamp to milliseconds with strict error handling.
|
||||
/// Returns Err with the invalid timestamp in the error message.
|
||||
fn parse_timestamp_strict(ts: &str) -> Result<i64, String> {
|
||||
DateTime::parse_from_rfc3339(ts)
|
||||
.map(|dt| dt.timestamp_millis())
|
||||
.map_err(|_| format!("Invalid timestamp: {}", ts))
|
||||
}
|
||||
|
||||
/// Transform notes from a GitLab discussion with strict timestamp parsing.
|
||||
/// Returns Err if any timestamp is invalid - no silent fallback to 0.
|
||||
pub fn transform_notes_with_diff_position(
|
||||
gitlab_discussion: &GitLabDiscussion,
|
||||
local_project_id: i64,
|
||||
) -> Result<Vec<NormalizedNote>, String> {
|
||||
let now = now_ms();
|
||||
|
||||
gitlab_discussion
|
||||
.notes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, note)| transform_single_note_strict(note, local_project_id, idx as i32, now))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn transform_single_note_strict(
|
||||
note: &GitLabNote,
|
||||
local_project_id: i64,
|
||||
position: i32,
|
||||
now: i64,
|
||||
) -> Result<NormalizedNote, String> {
|
||||
// Parse timestamps with strict error handling
|
||||
let created_at = parse_timestamp_strict(¬e.created_at)?;
|
||||
let updated_at = parse_timestamp_strict(¬e.updated_at)?;
|
||||
let resolved_at = match ¬e.resolved_at {
|
||||
Some(ts) => Some(parse_timestamp_strict(ts)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Extract DiffNote position fields if present
|
||||
let (
|
||||
position_old_path,
|
||||
position_new_path,
|
||||
position_old_line,
|
||||
position_new_line,
|
||||
position_type,
|
||||
position_line_range_start,
|
||||
position_line_range_end,
|
||||
position_base_sha,
|
||||
position_start_sha,
|
||||
position_head_sha,
|
||||
) = extract_position_fields(¬e.position);
|
||||
|
||||
Ok(NormalizedNote {
|
||||
gitlab_id: note.id,
|
||||
project_id: local_project_id,
|
||||
note_type: note.note_type.clone(),
|
||||
is_system: note.system,
|
||||
author_username: note.author.username.clone(),
|
||||
body: note.body.clone(),
|
||||
created_at,
|
||||
updated_at,
|
||||
last_seen_at: now,
|
||||
position,
|
||||
resolvable: note.resolvable,
|
||||
resolved: note.resolved,
|
||||
resolved_by: note.resolved_by.as_ref().map(|a| a.username.clone()),
|
||||
resolved_at,
|
||||
position_old_path,
|
||||
position_new_path,
|
||||
position_old_line,
|
||||
position_new_line,
|
||||
position_type,
|
||||
position_line_range_start,
|
||||
position_line_range_end,
|
||||
position_base_sha,
|
||||
position_start_sha,
|
||||
position_head_sha,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user