From dd5eb04953bfda442b5b993ba2abb607b5847e14 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Mon, 26 Jan 2026 11:28:21 -0500 Subject: [PATCH] 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 --- src/gitlab/client.rs | 384 ++++++++++++++++++++++++++ src/gitlab/mod.rs | 15 + src/gitlab/transformers/discussion.rs | 381 +++++++++++++++++++++++++ src/gitlab/transformers/issue.rs | 275 ++++++++++++++++++ src/gitlab/transformers/mod.rs | 7 + src/gitlab/types.rs | 143 ++++++++++ 6 files changed, 1205 insertions(+) create mode 100644 src/gitlab/client.rs create mode 100644 src/gitlab/mod.rs create mode 100644 src/gitlab/transformers/discussion.rs create mode 100644 src/gitlab/transformers/issue.rs create mode 100644 src/gitlab/transformers/mod.rs create mode 100644 src/gitlab/types.rs diff --git a/src/gitlab/client.rs b/src/gitlab/client.rs new file mode 100644 index 0000000..80995f4 --- /dev/null +++ b/src/gitlab/client.rs @@ -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>, +} + +impl GitLabClient { + /// Create a new GitLab client. + pub fn new(base_url: &str, token: &str, requests_per_second: Option) -> 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 { + self.request("/api/v4/user").await + } + + /// Get a project by its path. + pub async fn get_project(&self, path_with_namespace: &str) -> Result { + 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 { + self.request("/api/v4/version").await + } + + /// Make an authenticated API request. + async fn request(&self, path: &str) -> Result { + 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( + &self, + response: Response, + path: &str, + ) -> Result { + 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, + cursor_rewind_seconds: u32, + ) -> Pin> + 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::>(&path, ¶ms).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::().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> + 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::>(&path, ¶ms).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::().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( + &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 { + DateTime::::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); + } +} diff --git a/src/gitlab/mod.rs b/src/gitlab/mod.rs new file mode 100644 index 0000000..d46c1d2 --- /dev/null +++ b/src/gitlab/mod.rs @@ -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, +}; diff --git a/src/gitlab/transformers/discussion.rs b/src/gitlab/transformers/discussion.rs new file mode 100644 index 0000000..0bb46a2 --- /dev/null +++ b/src/gitlab/transformers/discussion.rs @@ -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, // min(note.created_at) in ms epoch + pub last_note_at: Option, // 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, // "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, + pub resolved_at: Option, +} + +/// Parse ISO 8601 timestamp to milliseconds, returning None on failure. +fn parse_timestamp_opt(ts: &str) -> Option { + 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 = 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 { + 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(¬e.created_at), + updated_at: parse_timestamp(¬e.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) -> 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(¬_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); + } +} diff --git a/src/gitlab/transformers/issue.rs b/src/gitlab/transformers/issue.rs new file mode 100644 index 0000000..f21b115 --- /dev/null +++ b/src/gitlab/transformers/issue.rs @@ -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, + 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())); + } +} diff --git a/src/gitlab/transformers/mod.rs b/src/gitlab/transformers/mod.rs new file mode 100644 index 0000000..5bbbb54 --- /dev/null +++ b/src/gitlab/transformers/mod.rs @@ -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}; diff --git a/src/gitlab/types.rs b/src/gitlab/types.rs new file mode 100644 index 0000000..2a8a540 --- /dev/null +++ b/src/gitlab/types.rs @@ -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, + pub avatar_url: Option, + pub web_url: Option, + pub created_at: Option, + pub state: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GitLabProject { + pub id: i64, + pub path_with_namespace: String, + pub default_branch: Option, + pub web_url: String, + pub created_at: String, + pub updated_at: String, + pub name: Option, + pub description: Option, + pub visibility: Option, + pub archived: Option, +} + +#[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, + pub title: String, + pub description: Option, + /// "active" or "closed". + pub state: Option, + /// YYYY-MM-DD format. + pub due_date: Option, + pub web_url: Option, +} + +/// 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, + /// "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, + pub author: GitLabAuthor, + /// Assignees (can be multiple). + #[serde(default)] + pub assignees: Vec, + /// Array of label names (not label details). + pub labels: Vec, + /// Associated milestone (if any). + pub milestone: Option, + /// Due date in YYYY-MM-DD format. + pub due_date: Option, + 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, +} + +/// 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, + 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, + /// When this note was resolved (if resolved). + pub resolved_at: Option, + /// Position metadata for DiffNotes (code review comments). + pub position: Option, +} + +/// Position metadata for DiffNotes (code review comments on specific lines). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GitLabNotePosition { + pub old_path: Option, + pub new_path: Option, + pub old_line: Option, + pub new_line: Option, +}