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

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()));
}
}