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:
384
src/gitlab/client.rs
Normal file
384
src/gitlab/client.rs
Normal 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, ¶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::<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, ¶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::<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
15
src/gitlab/mod.rs
Normal 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,
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
275
src/gitlab/transformers/issue.rs
Normal file
275
src/gitlab/transformers/issue.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
7
src/gitlab/transformers/mod.rs
Normal file
7
src/gitlab/transformers/mod.rs
Normal 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
143
src/gitlab/types.rs
Normal 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>,
|
||||
}
|
||||
Reference in New Issue
Block a user