feat(gitlab): Implement GitLab REST API client and type definitions

Provides a typed interface to the GitLab API with pagination support.

src/gitlab/types.rs - API response type definitions:
- GitLabIssue: Full issue payload with author, assignees, labels
- GitLabDiscussion: Discussion thread with notes array
- GitLabNote: Individual note with author, timestamps, body
- GitLabAuthor/GitLabUser: User information with avatar URLs
- GitLabProject: Project metadata from /api/v4/projects
- GitLabVersion: GitLab instance version from /api/v4/version
- GitLabNotePosition: Line-level position for diff notes
- All types derive Deserialize for JSON parsing

src/gitlab/client.rs - HTTP client with authentication:
- Bearer token authentication from config
- Base URL configuration for self-hosted instances
- Paginated iteration via keyset or offset pagination
- Automatic Link header parsing for next page URLs
- Per-page limit control (default 100)
- Methods: get_user(), get_version(), get_project()
- Async stream for issues: list_issues_paginated()
- Async stream for discussions: list_issue_discussions_paginated()
- Respects GitLab rate limiting via response headers

src/gitlab/transformers/ - API to database mapping:

transformers/issue.rs - Issue transformation:
- Maps GitLabIssue to IssueRow for database insert
- Extracts milestone ID and due date
- Normalizes author/assignee usernames
- Preserves label IDs for junction table
- Returns IssueWithMetadata including label/assignee lists

transformers/discussion.rs - Discussion transformation:
- Maps GitLabDiscussion to NormalizedDiscussion
- Extracts thread metadata (resolvable, resolved)
- Flattens notes to NormalizedNote with foreign keys
- Handles system notes vs user notes
- Preserves note position for diff discussions

transformers/mod.rs - Re-exports all transformer types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-01-26 11:28:21 -05:00
parent 7aaa51f645
commit dd5eb04953
6 changed files with 1205 additions and 0 deletions

384
src/gitlab/client.rs Normal file
View File

@@ -0,0 +1,384 @@
//! GitLab API client with rate limiting and error handling.
use async_stream::stream;
use chrono::{DateTime, Utc};
use futures::Stream;
use reqwest::header::{ACCEPT, HeaderMap, HeaderValue};
use reqwest::{Client, Response, StatusCode};
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tokio::time::sleep;
use tracing::debug;
use super::types::{GitLabDiscussion, GitLabIssue, GitLabProject, GitLabUser, GitLabVersion};
use crate::core::error::{GiError, Result};
/// Simple rate limiter with jitter to prevent thundering herd.
struct RateLimiter {
last_request: Instant,
min_interval: Duration,
}
impl RateLimiter {
fn new(requests_per_second: f64) -> Self {
Self {
last_request: Instant::now() - Duration::from_secs(1), // Allow immediate first request
min_interval: Duration::from_secs_f64(1.0 / requests_per_second),
}
}
async fn acquire(&mut self) {
let elapsed = self.last_request.elapsed();
if elapsed < self.min_interval {
// Add 0-50ms jitter to prevent synchronized requests
let jitter = Duration::from_millis(rand_jitter());
let wait_time = self.min_interval - elapsed + jitter;
sleep(wait_time).await;
}
self.last_request = Instant::now();
}
}
/// Generate random jitter between 0-50ms without external crate.
fn rand_jitter() -> u64 {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
// RandomState is seeded randomly each time, so just hashing the state address gives us jitter
let state = RandomState::new();
let mut hasher = state.build_hasher();
// Hash the address of the state (random per call) + current time nanos for more entropy
hasher.write_usize(&state as *const _ as usize);
hasher.write_u128(std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos());
hasher.finish() % 50
}
/// GitLab API client with rate limiting.
pub struct GitLabClient {
client: Client,
base_url: String,
token: String,
rate_limiter: Arc<Mutex<RateLimiter>>,
}
impl GitLabClient {
/// Create a new GitLab client.
pub fn new(base_url: &str, token: &str, requests_per_second: Option<f64>) -> Self {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
let client = Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
token: token.to_string(),
rate_limiter: Arc::new(Mutex::new(RateLimiter::new(
requests_per_second.unwrap_or(10.0),
))),
}
}
/// Get the currently authenticated user.
pub async fn get_current_user(&self) -> Result<GitLabUser> {
self.request("/api/v4/user").await
}
/// Get a project by its path.
pub async fn get_project(&self, path_with_namespace: &str) -> Result<GitLabProject> {
let encoded = urlencoding::encode(path_with_namespace);
self.request(&format!("/api/v4/projects/{encoded}")).await
}
/// Get GitLab server version.
pub async fn get_version(&self) -> Result<GitLabVersion> {
self.request("/api/v4/version").await
}
/// Make an authenticated API request.
async fn request<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
self.rate_limiter.lock().await.acquire().await;
let url = format!("{}{}", self.base_url, path);
debug!(url = %url, "GitLab request");
let response = self
.client
.get(&url)
.header("PRIVATE-TOKEN", &self.token)
.send()
.await
.map_err(|e| GiError::GitLabNetworkError {
base_url: self.base_url.clone(),
source: Some(e),
})?;
self.handle_response(response, path).await
}
/// Handle API response, converting errors appropriately.
async fn handle_response<T: serde::de::DeserializeOwned>(
&self,
response: Response,
path: &str,
) -> Result<T> {
match response.status() {
StatusCode::UNAUTHORIZED => Err(GiError::GitLabAuthFailed),
StatusCode::NOT_FOUND => Err(GiError::GitLabNotFound {
resource: path.to_string(),
}),
StatusCode::TOO_MANY_REQUESTS => {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
Err(GiError::GitLabRateLimited { retry_after })
}
status if status.is_success() => {
let body = response.json().await?;
Ok(body)
}
status => Err(GiError::Other(format!(
"GitLab API error: {} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown")
))),
}
}
/// Paginate through issues for a project.
///
/// Returns an async stream of issues, handling pagination automatically.
/// Issues are ordered by updated_at ascending to support cursor-based sync.
///
/// # Arguments
/// * `gitlab_project_id` - The GitLab project ID
/// * `updated_after` - Optional cursor (ms epoch) - only fetch issues updated after this
/// * `cursor_rewind_seconds` - Rewind cursor by this many seconds to handle edge cases
pub fn paginate_issues(
&self,
gitlab_project_id: i64,
updated_after: Option<i64>,
cursor_rewind_seconds: u32,
) -> Pin<Box<dyn Stream<Item = Result<GitLabIssue>> + Send + '_>> {
Box::pin(stream! {
let mut page = 1u32;
let per_page = 100u32;
// Apply cursor rewind, clamping to 0
let rewound_cursor = updated_after.map(|ts| {
let rewind_ms = (cursor_rewind_seconds as i64) * 1000;
(ts - rewind_ms).max(0)
});
loop {
let mut params = vec![
("scope", "all".to_string()),
("state", "all".to_string()),
("order_by", "updated_at".to_string()),
("sort", "asc".to_string()),
("per_page", per_page.to_string()),
("page", page.to_string()),
];
// Add updated_after if we have a cursor
if let Some(ts_ms) = rewound_cursor
&& let Some(iso) = ms_to_iso8601(ts_ms)
{
params.push(("updated_after", iso));
}
let path = format!("/api/v4/projects/{}/issues", gitlab_project_id);
let result = self.request_with_headers::<Vec<GitLabIssue>>(&path, &params).await;
match result {
Ok((issues, headers)) => {
let is_empty = issues.is_empty();
// Yield each issue
for issue in issues {
yield Ok(issue);
}
// Check for next page
let next_page = headers
.get("x-next-page")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok());
match next_page {
Some(next) if next > page => {
page = next;
}
_ => {
// No next page or empty response - we're done
if is_empty {
break;
}
// Check if current page returned less than per_page (last page)
break;
}
}
}
Err(e) => {
yield Err(e);
break;
}
}
}
})
}
/// Paginate through discussions for an issue.
///
/// Returns an async stream of discussions, handling pagination automatically.
pub fn paginate_issue_discussions(
&self,
gitlab_project_id: i64,
issue_iid: i64,
) -> Pin<Box<dyn Stream<Item = Result<GitLabDiscussion>> + Send + '_>> {
Box::pin(stream! {
let mut page = 1u32;
let per_page = 100u32;
loop {
let params = vec![
("per_page", per_page.to_string()),
("page", page.to_string()),
];
let path = format!(
"/api/v4/projects/{}/issues/{}/discussions",
gitlab_project_id, issue_iid
);
let result = self.request_with_headers::<Vec<GitLabDiscussion>>(&path, &params).await;
match result {
Ok((discussions, headers)) => {
let is_empty = discussions.is_empty();
for discussion in discussions {
yield Ok(discussion);
}
let next_page = headers
.get("x-next-page")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok());
match next_page {
Some(next) if next > page => {
page = next;
}
_ => {
if is_empty {
break;
}
break;
}
}
}
Err(e) => {
yield Err(e);
break;
}
}
}
})
}
/// Make an authenticated API request with query parameters, returning headers.
async fn request_with_headers<T: serde::de::DeserializeOwned>(
&self,
path: &str,
params: &[(&str, String)],
) -> Result<(T, HeaderMap)> {
self.rate_limiter.lock().await.acquire().await;
let url = format!("{}{}", self.base_url, path);
debug!(url = %url, ?params, "GitLab paginated request");
let response = self
.client
.get(&url)
.query(params)
.header("PRIVATE-TOKEN", &self.token)
.send()
.await
.map_err(|e| GiError::GitLabNetworkError {
base_url: self.base_url.clone(),
source: Some(e),
})?;
let headers = response.headers().clone();
let body = self.handle_response(response, path).await?;
Ok((body, headers))
}
}
/// Convert milliseconds since epoch to ISO 8601 string.
fn ms_to_iso8601(ms: i64) -> Option<String> {
DateTime::<Utc>::from_timestamp_millis(ms)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ms_to_iso8601_converts_correctly() {
// 2024-01-15T10:00:00.000Z = 1705312800000 ms
let result = ms_to_iso8601(1705312800000);
assert_eq!(result, Some("2024-01-15T10:00:00.000Z".to_string()));
}
#[test]
fn ms_to_iso8601_handles_zero() {
let result = ms_to_iso8601(0);
assert_eq!(result, Some("1970-01-01T00:00:00.000Z".to_string()));
}
#[test]
fn cursor_rewind_clamps_to_zero() {
let updated_after = Some(1000i64); // 1 second
let cursor_rewind_seconds = 10u32; // 10 seconds
// Rewind would be negative, should clamp to 0
let rewind_ms = (cursor_rewind_seconds as i64) * 1000;
let rewound = (updated_after.unwrap() - rewind_ms).max(0);
assert_eq!(rewound, 0);
}
#[test]
fn cursor_rewind_applies_correctly() {
let updated_after = Some(1705312800000i64); // 2024-01-15T10:00:00.000Z
let cursor_rewind_seconds = 60u32; // 1 minute
let rewind_ms = (cursor_rewind_seconds as i64) * 1000;
let rewound = (updated_after.unwrap() - rewind_ms).max(0);
// Should be 1 minute earlier
assert_eq!(rewound, 1705312740000);
}
}

15
src/gitlab/mod.rs Normal file
View File

@@ -0,0 +1,15 @@
//! GitLab API client and types.
pub mod client;
pub mod transformers;
pub mod types;
pub use client::GitLabClient;
pub use transformers::{
IssueRow, IssueWithMetadata, MilestoneRow, NormalizedDiscussion, NormalizedNote,
transform_discussion, transform_issue, transform_notes,
};
pub use types::{
GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabNote, GitLabNotePosition, GitLabProject,
GitLabUser, GitLabVersion,
};

View File

@@ -0,0 +1,381 @@
//! Discussion and note transformers: convert GitLab discussions to local schema.
use chrono::DateTime;
use crate::core::time::now_ms;
use crate::gitlab::types::{GitLabDiscussion, GitLabNote};
/// Normalized discussion for local storage.
#[derive(Debug, Clone)]
pub struct NormalizedDiscussion {
pub gitlab_discussion_id: String,
pub project_id: i64,
pub issue_id: i64,
pub noteable_type: String, // "Issue"
pub individual_note: bool,
pub first_note_at: Option<i64>, // min(note.created_at) in ms epoch
pub last_note_at: Option<i64>, // max(note.created_at) in ms epoch
pub last_seen_at: i64,
pub resolvable: bool, // any note is resolvable
pub resolved: bool, // all resolvable notes are resolved
}
/// Normalized note for local storage.
#[derive(Debug, Clone)]
pub struct NormalizedNote {
pub gitlab_id: i64,
pub project_id: i64,
pub note_type: Option<String>, // "DiscussionNote" | "DiffNote" | null
pub is_system: bool,
pub author_username: String,
pub body: String,
pub created_at: i64, // ms epoch
pub updated_at: i64, // ms epoch
pub last_seen_at: i64,
pub position: i32, // 0-indexed array position
pub resolvable: bool,
pub resolved: bool,
pub resolved_by: Option<String>,
pub resolved_at: Option<i64>,
}
/// Parse ISO 8601 timestamp to milliseconds, returning None on failure.
fn parse_timestamp_opt(ts: &str) -> Option<i64> {
DateTime::parse_from_rfc3339(ts)
.ok()
.map(|dt| dt.timestamp_millis())
}
/// Parse ISO 8601 timestamp to milliseconds, defaulting to 0 on failure.
fn parse_timestamp(ts: &str) -> i64 {
parse_timestamp_opt(ts).unwrap_or(0)
}
/// Transform a GitLab discussion into normalized schema.
pub fn transform_discussion(
gitlab_discussion: &GitLabDiscussion,
local_project_id: i64,
local_issue_id: i64,
) -> NormalizedDiscussion {
let now = now_ms();
// Compute first_note_at and last_note_at from notes
let note_timestamps: Vec<i64> = gitlab_discussion
.notes
.iter()
.filter_map(|n| parse_timestamp_opt(&n.created_at))
.collect();
let first_note_at = note_timestamps.iter().min().copied();
let last_note_at = note_timestamps.iter().max().copied();
// Compute resolvable: any note is resolvable
let resolvable = gitlab_discussion.notes.iter().any(|n| n.resolvable);
// Compute resolved: all resolvable notes are resolved
let resolved = if resolvable {
gitlab_discussion
.notes
.iter()
.filter(|n| n.resolvable)
.all(|n| n.resolved)
} else {
false
};
NormalizedDiscussion {
gitlab_discussion_id: gitlab_discussion.id.clone(),
project_id: local_project_id,
issue_id: local_issue_id,
noteable_type: "Issue".to_string(),
individual_note: gitlab_discussion.individual_note,
first_note_at,
last_note_at,
last_seen_at: now,
resolvable,
resolved,
}
}
/// Transform notes from a GitLab discussion into normalized schema.
pub fn transform_notes(
gitlab_discussion: &GitLabDiscussion,
local_project_id: i64,
) -> Vec<NormalizedNote> {
let now = now_ms();
gitlab_discussion
.notes
.iter()
.enumerate()
.map(|(idx, note)| transform_single_note(note, local_project_id, idx as i32, now))
.collect()
}
fn transform_single_note(
note: &GitLabNote,
local_project_id: i64,
position: i32,
now: i64,
) -> NormalizedNote {
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: parse_timestamp(&note.created_at),
updated_at: parse_timestamp(&note.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: note
.resolved_at
.as_ref()
.and_then(|ts| parse_timestamp_opt(ts)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gitlab::types::GitLabAuthor;
fn make_test_note(
id: i64,
created_at: &str,
system: bool,
resolvable: bool,
resolved: bool,
) -> GitLabNote {
GitLabNote {
id,
note_type: Some("DiscussionNote".to_string()),
body: format!("Note {}", id),
author: GitLabAuthor {
id: 1,
username: "testuser".to_string(),
name: "Test User".to_string(),
},
created_at: created_at.to_string(),
updated_at: created_at.to_string(),
system,
resolvable,
resolved,
resolved_by: None,
resolved_at: None,
position: None,
}
}
fn make_test_discussion(individual_note: bool, notes: Vec<GitLabNote>) -> GitLabDiscussion {
GitLabDiscussion {
id: "6a9c1750b37d513a43987b574953fceb50b03ce7".to_string(),
individual_note,
notes,
}
}
#[test]
fn transforms_discussion_payload_to_normalized_schema() {
let discussion = make_test_discussion(
false,
vec![make_test_note(
1,
"2024-01-16T09:00:00.000Z",
false,
false,
false,
)],
);
let result = transform_discussion(&discussion, 100, 42);
assert_eq!(
result.gitlab_discussion_id,
"6a9c1750b37d513a43987b574953fceb50b03ce7"
);
assert_eq!(result.project_id, 100);
assert_eq!(result.issue_id, 42);
assert_eq!(result.noteable_type, "Issue");
assert!(!result.individual_note);
}
#[test]
fn extracts_notes_array_from_discussion() {
let discussion = make_test_discussion(
false,
vec![
make_test_note(1, "2024-01-16T09:00:00.000Z", false, false, false),
make_test_note(2, "2024-01-16T10:00:00.000Z", false, false, false),
],
);
let notes = transform_notes(&discussion, 100);
assert_eq!(notes.len(), 2);
assert_eq!(notes[0].gitlab_id, 1);
assert_eq!(notes[1].gitlab_id, 2);
}
#[test]
fn sets_individual_note_flag_correctly() {
let threaded = make_test_discussion(
false,
vec![make_test_note(
1,
"2024-01-16T09:00:00.000Z",
false,
false,
false,
)],
);
let standalone = make_test_discussion(
true,
vec![make_test_note(
1,
"2024-01-16T09:00:00.000Z",
false,
false,
false,
)],
);
assert!(!transform_discussion(&threaded, 100, 42).individual_note);
assert!(transform_discussion(&standalone, 100, 42).individual_note);
}
#[test]
fn flags_system_notes_with_is_system_true() {
let discussion = make_test_discussion(
false,
vec![
make_test_note(1, "2024-01-16T09:00:00.000Z", false, false, false),
make_test_note(2, "2024-01-16T09:00:00.000Z", true, false, false), // system note
],
);
let notes = transform_notes(&discussion, 100);
assert!(!notes[0].is_system);
assert!(notes[1].is_system);
}
#[test]
fn preserves_note_order_via_position_field() {
let discussion = make_test_discussion(
false,
vec![
make_test_note(1, "2024-01-16T09:00:00.000Z", false, false, false),
make_test_note(2, "2024-01-16T10:00:00.000Z", false, false, false),
make_test_note(3, "2024-01-16T11:00:00.000Z", false, false, false),
],
);
let notes = transform_notes(&discussion, 100);
assert_eq!(notes[0].position, 0);
assert_eq!(notes[1].position, 1);
assert_eq!(notes[2].position, 2);
}
#[test]
fn computes_first_note_at_and_last_note_at_correctly() {
let discussion = make_test_discussion(
false,
vec![
make_test_note(1, "2024-01-16T09:00:00.000Z", false, false, false),
make_test_note(2, "2024-01-16T11:00:00.000Z", false, false, false), // latest
make_test_note(3, "2024-01-16T10:00:00.000Z", false, false, false),
],
);
let result = transform_discussion(&discussion, 100, 42);
// first_note_at should be 09:00 (note 1)
assert_eq!(result.first_note_at, Some(1705395600000));
// last_note_at should be 11:00 (note 2)
assert_eq!(result.last_note_at, Some(1705402800000));
}
#[test]
fn single_note_has_equal_first_and_last() {
let discussion = make_test_discussion(
false,
vec![make_test_note(
1,
"2024-01-16T09:00:00.000Z",
false,
false,
false,
)],
);
let result = transform_discussion(&discussion, 100, 42);
assert_eq!(result.first_note_at, result.last_note_at);
assert_eq!(result.first_note_at, Some(1705395600000));
}
#[test]
fn computes_resolvable_when_any_note_is_resolvable() {
let not_resolvable = make_test_discussion(
false,
vec![
make_test_note(1, "2024-01-16T09:00:00.000Z", false, false, false),
make_test_note(2, "2024-01-16T10:00:00.000Z", false, false, false),
],
);
let resolvable = make_test_discussion(
false,
vec![
make_test_note(1, "2024-01-16T09:00:00.000Z", false, true, false), // resolvable
make_test_note(2, "2024-01-16T10:00:00.000Z", false, false, false),
],
);
assert!(!transform_discussion(&not_resolvable, 100, 42).resolvable);
assert!(transform_discussion(&resolvable, 100, 42).resolvable);
}
#[test]
fn computes_resolved_only_when_all_resolvable_notes_resolved() {
// Mix of resolved/unresolved - not resolved
let partial = make_test_discussion(
false,
vec![
make_test_note(1, "2024-01-16T09:00:00.000Z", false, true, true), // resolved
make_test_note(2, "2024-01-16T10:00:00.000Z", false, true, false), // not resolved
],
);
// All resolvable notes resolved
let fully_resolved = make_test_discussion(
false,
vec![
make_test_note(1, "2024-01-16T09:00:00.000Z", false, true, true),
make_test_note(2, "2024-01-16T10:00:00.000Z", false, true, true),
],
);
// No resolvable notes - resolved should be false
let no_resolvable = make_test_discussion(
false,
vec![make_test_note(
1,
"2024-01-16T09:00:00.000Z",
false,
false,
false,
)],
);
assert!(!transform_discussion(&partial, 100, 42).resolved);
assert!(transform_discussion(&fully_resolved, 100, 42).resolved);
assert!(!transform_discussion(&no_resolvable, 100, 42).resolved);
}
}

View File

@@ -0,0 +1,275 @@
//! 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()));
}
}

View File

@@ -0,0 +1,7 @@
//! Transformers for converting GitLab API responses to local schema.
pub mod discussion;
pub mod issue;
pub use discussion::{NormalizedDiscussion, NormalizedNote, transform_discussion, transform_notes};
pub use issue::{IssueRow, IssueWithMetadata, MilestoneRow, transform_issue};

143
src/gitlab/types.rs Normal file
View File

@@ -0,0 +1,143 @@
//! GitLab API response types.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabUser {
pub id: i64,
pub username: String,
pub name: String,
pub email: Option<String>,
pub avatar_url: Option<String>,
pub web_url: Option<String>,
pub created_at: Option<String>,
pub state: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabProject {
pub id: i64,
pub path_with_namespace: String,
pub default_branch: Option<String>,
pub web_url: String,
pub created_at: String,
pub updated_at: String,
pub name: Option<String>,
pub description: Option<String>,
pub visibility: Option<String>,
pub archived: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabVersion {
pub version: String,
pub revision: String,
}
// === Checkpoint 1: Issue/Discussion types ===
/// Author information embedded in issues, notes, etc.
/// Note: This is a simplified author - GitLabUser has more fields.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabAuthor {
pub id: i64,
pub username: String,
pub name: String,
}
/// GitLab Milestone (embedded in issues).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMilestone {
pub id: i64,
pub iid: i64,
pub project_id: Option<i64>,
pub title: String,
pub description: Option<String>,
/// "active" or "closed".
pub state: Option<String>,
/// YYYY-MM-DD format.
pub due_date: Option<String>,
pub web_url: Option<String>,
}
/// GitLab Issue from /projects/:id/issues endpoint.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabIssue {
/// GitLab global ID (unique across all projects).
pub id: i64,
/// Project-scoped issue number (the number shown in the UI).
pub iid: i64,
/// The project this issue belongs to.
pub project_id: i64,
pub title: String,
pub description: Option<String>,
/// "opened" or "closed".
pub state: String,
/// ISO 8601 timestamp.
pub created_at: String,
/// ISO 8601 timestamp.
pub updated_at: String,
/// ISO 8601 timestamp when closed (null if open).
pub closed_at: Option<String>,
pub author: GitLabAuthor,
/// Assignees (can be multiple).
#[serde(default)]
pub assignees: Vec<GitLabAuthor>,
/// Array of label names (not label details).
pub labels: Vec<String>,
/// Associated milestone (if any).
pub milestone: Option<GitLabMilestone>,
/// Due date in YYYY-MM-DD format.
pub due_date: Option<String>,
pub web_url: String,
}
/// GitLab Discussion from /projects/:id/issues/:iid/discussions endpoint.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabDiscussion {
/// String ID (e.g., "6a9c1750b37d...").
pub id: String,
/// True if this is a standalone comment, false if it's a threaded discussion.
pub individual_note: bool,
/// Notes in this discussion (always at least one).
pub notes: Vec<GitLabNote>,
}
/// A single note/comment within a discussion.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabNote {
pub id: i64,
/// "DiscussionNote", "DiffNote", or null for simple notes.
/// Using rename because "type" is a reserved keyword in Rust.
#[serde(rename = "type")]
pub note_type: Option<String>,
pub body: String,
pub author: GitLabAuthor,
/// ISO 8601 timestamp.
pub created_at: String,
/// ISO 8601 timestamp.
pub updated_at: String,
/// True for system-generated notes (label changes, assignments, etc.).
pub system: bool,
/// Whether this note can be resolved (MR discussions).
#[serde(default)]
pub resolvable: bool,
/// Whether this note has been resolved.
#[serde(default)]
pub resolved: bool,
/// Who resolved this note (if resolved).
pub resolved_by: Option<GitLabAuthor>,
/// When this note was resolved (if resolved).
pub resolved_at: Option<String>,
/// Position metadata for DiffNotes (code review comments).
pub position: Option<GitLabNotePosition>,
}
/// Position metadata for DiffNotes (code review comments on specific lines).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabNotePosition {
pub old_path: Option<String>,
pub new_path: Option<String>,
pub old_line: Option<i32>,
pub new_line: Option<i32>,
}