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:
155
src/gitlab/transformers/merge_request.rs
Normal file
155
src/gitlab/transformers/merge_request.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! Merge request transformer: converts GitLabMergeRequest to local schema.
|
||||
|
||||
use chrono::DateTime;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::gitlab::types::GitLabMergeRequest;
|
||||
|
||||
/// Get current time in milliseconds since Unix epoch.
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
/// Parse ISO 8601 timestamp to milliseconds since Unix epoch.
|
||||
fn iso_to_ms(ts: &str) -> Result<i64, String> {
|
||||
DateTime::parse_from_rfc3339(ts)
|
||||
.map(|dt| dt.timestamp_millis())
|
||||
.map_err(|e| format!("Failed to parse timestamp '{}': {}", ts, e))
|
||||
}
|
||||
|
||||
/// Parse optional ISO 8601 timestamp to optional milliseconds since Unix epoch.
|
||||
fn iso_to_ms_opt(ts: &Option<String>) -> Result<Option<i64>, String> {
|
||||
match ts {
|
||||
Some(s) => iso_to_ms(s).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Local schema representation of a merge request row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NormalizedMergeRequest {
|
||||
pub gitlab_id: i64,
|
||||
pub project_id: i64,
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub state: String,
|
||||
pub draft: bool,
|
||||
pub author_username: String,
|
||||
pub source_branch: String,
|
||||
pub target_branch: String,
|
||||
pub head_sha: Option<String>,
|
||||
pub references_short: Option<String>,
|
||||
pub references_full: Option<String>,
|
||||
pub detailed_merge_status: Option<String>,
|
||||
pub merge_user_username: Option<String>,
|
||||
pub created_at: i64, // ms epoch UTC
|
||||
pub updated_at: i64, // ms epoch UTC
|
||||
pub merged_at: Option<i64>, // ms epoch UTC
|
||||
pub closed_at: Option<i64>, // ms epoch UTC
|
||||
pub last_seen_at: i64, // ms epoch UTC
|
||||
pub web_url: String,
|
||||
}
|
||||
|
||||
/// Merge request bundled with extracted metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MergeRequestWithMetadata {
|
||||
pub merge_request: NormalizedMergeRequest,
|
||||
pub label_names: Vec<String>,
|
||||
pub assignee_usernames: Vec<String>,
|
||||
pub reviewer_usernames: Vec<String>,
|
||||
}
|
||||
|
||||
/// Transform a GitLab merge request into local schema format.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `gitlab_mr` - The GitLab MR API response
|
||||
/// * `local_project_id` - The local database project ID (not GitLab's project_id)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(MergeRequestWithMetadata)` - Transformed MR with extracted metadata
|
||||
/// * `Err(String)` - Error message if transformation fails (e.g., invalid timestamps)
|
||||
pub fn transform_merge_request(
|
||||
gitlab_mr: &GitLabMergeRequest,
|
||||
local_project_id: i64,
|
||||
) -> Result<MergeRequestWithMetadata, String> {
|
||||
// Parse required timestamps
|
||||
let created_at = iso_to_ms(&gitlab_mr.created_at)?;
|
||||
let updated_at = iso_to_ms(&gitlab_mr.updated_at)?;
|
||||
|
||||
// Parse optional timestamps
|
||||
let merged_at = iso_to_ms_opt(&gitlab_mr.merged_at)?;
|
||||
let closed_at = iso_to_ms_opt(&gitlab_mr.closed_at)?;
|
||||
|
||||
// Draft: prefer draft, fallback to work_in_progress
|
||||
let is_draft = gitlab_mr.draft || gitlab_mr.work_in_progress;
|
||||
|
||||
// Merge status: prefer detailed_merge_status over legacy
|
||||
let detailed_merge_status = gitlab_mr
|
||||
.detailed_merge_status
|
||||
.clone()
|
||||
.or_else(|| gitlab_mr.merge_status_legacy.clone());
|
||||
|
||||
// Merge user: prefer merge_user over merged_by
|
||||
let merge_user_username = gitlab_mr
|
||||
.merge_user
|
||||
.as_ref()
|
||||
.map(|u| u.username.clone())
|
||||
.or_else(|| gitlab_mr.merged_by.as_ref().map(|u| u.username.clone()));
|
||||
|
||||
// References extraction
|
||||
let (references_short, references_full) = gitlab_mr
|
||||
.references
|
||||
.as_ref()
|
||||
.map(|r| (Some(r.short.clone()), Some(r.full.clone())))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// Head SHA
|
||||
let head_sha = gitlab_mr.sha.clone();
|
||||
|
||||
// Extract assignee usernames
|
||||
let assignee_usernames: Vec<String> = gitlab_mr
|
||||
.assignees
|
||||
.iter()
|
||||
.map(|a| a.username.clone())
|
||||
.collect();
|
||||
|
||||
// Extract reviewer usernames
|
||||
let reviewer_usernames: Vec<String> = gitlab_mr
|
||||
.reviewers
|
||||
.iter()
|
||||
.map(|r| r.username.clone())
|
||||
.collect();
|
||||
|
||||
Ok(MergeRequestWithMetadata {
|
||||
merge_request: NormalizedMergeRequest {
|
||||
gitlab_id: gitlab_mr.id,
|
||||
project_id: local_project_id,
|
||||
iid: gitlab_mr.iid,
|
||||
title: gitlab_mr.title.clone(),
|
||||
description: gitlab_mr.description.clone(),
|
||||
state: gitlab_mr.state.clone(),
|
||||
draft: is_draft,
|
||||
author_username: gitlab_mr.author.username.clone(),
|
||||
source_branch: gitlab_mr.source_branch.clone(),
|
||||
target_branch: gitlab_mr.target_branch.clone(),
|
||||
head_sha,
|
||||
references_short,
|
||||
references_full,
|
||||
detailed_merge_status,
|
||||
merge_user_username,
|
||||
created_at,
|
||||
updated_at,
|
||||
merged_at,
|
||||
closed_at,
|
||||
last_seen_at: now_ms(),
|
||||
web_url: gitlab_mr.web_url.clone(),
|
||||
},
|
||||
label_names: gitlab_mr.labels.clone(),
|
||||
assignee_usernames,
|
||||
reviewer_usernames,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user