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:
381
src/gitlab/transformers/discussion.rs
Normal file
381
src/gitlab/transformers/discussion.rs
Normal 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(¬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<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(¬_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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user