feat(gitlab): Add MR and MR discussion API endpoints to client
Extends GitLabClient with endpoints for fetching merge requests and their discussions, following the same patterns established for issues. New methods: - fetch_merge_requests(): Paginated MR listing with cursor support, using updated_after filter for incremental sync. Uses 'all' scope to include MRs where user is author/assignee/reviewer. - fetch_merge_requests_single_page(): Single page variant for callers managing their own pagination (used by parallel prefetch) - fetch_mr_discussions(): Paginated discussion listing for a single MR, returns full discussion trees with notes API design notes: - Uses keyset pagination (order_by=updated_at, keyset=true) for consistent results during sync operations - MR endpoint uses /merge_requests (not /mrs) per GitLab API naming - Discussion endpoint matches issue pattern for consistency - Per_page defaults to 100 (GitLab max) for efficiency The fetch_merge_requests_single_page method enables the parallel prefetch strategy used in mr_discussions.rs, where multiple MRs' discussions are fetched concurrently during the sweep phase. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,9 @@ use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
use tracing::debug;
|
||||
|
||||
use super::types::{GitLabDiscussion, GitLabIssue, GitLabProject, GitLabUser, GitLabVersion};
|
||||
use super::types::{
|
||||
GitLabDiscussion, GitLabIssue, GitLabMergeRequest, GitLabProject, GitLabUser, GitLabVersion,
|
||||
};
|
||||
use crate::core::error::{GiError, Result};
|
||||
|
||||
/// Simple rate limiter with jitter to prevent thundering herd.
|
||||
@@ -53,10 +55,12 @@ fn rand_jitter() -> u64 {
|
||||
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.write_u128(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos(),
|
||||
);
|
||||
hasher.finish() % 50
|
||||
}
|
||||
|
||||
@@ -305,6 +309,182 @@ impl GitLabClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Paginate through merge requests for a project.
|
||||
///
|
||||
/// Returns an async stream of merge requests, handling pagination automatically.
|
||||
/// MRs 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 MRs updated after this
|
||||
/// * `cursor_rewind_seconds` - Rewind cursor by this many seconds to handle edge cases
|
||||
pub fn paginate_merge_requests(
|
||||
&self,
|
||||
gitlab_project_id: i64,
|
||||
updated_after: Option<i64>,
|
||||
cursor_rewind_seconds: u32,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>> + Send + '_>> {
|
||||
Box::pin(stream! {
|
||||
let mut page = 1u32;
|
||||
let per_page = 100u32;
|
||||
|
||||
loop {
|
||||
let page_result = self
|
||||
.fetch_merge_requests_page(
|
||||
gitlab_project_id,
|
||||
updated_after,
|
||||
cursor_rewind_seconds,
|
||||
page,
|
||||
per_page,
|
||||
)
|
||||
.await;
|
||||
|
||||
match page_result {
|
||||
Ok(mr_page) => {
|
||||
for mr in mr_page.items {
|
||||
yield Ok(mr);
|
||||
}
|
||||
|
||||
if mr_page.is_last_page {
|
||||
break;
|
||||
}
|
||||
|
||||
match mr_page.next_page {
|
||||
Some(np) => page = np,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
yield Err(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch a single page of merge requests with pagination metadata.
|
||||
pub async fn fetch_merge_requests_page(
|
||||
&self,
|
||||
gitlab_project_id: i64,
|
||||
updated_after: Option<i64>,
|
||||
cursor_rewind_seconds: u32,
|
||||
page: u32,
|
||||
per_page: u32,
|
||||
) -> Result<MergeRequestPage> {
|
||||
// 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)
|
||||
});
|
||||
|
||||
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/{}/merge_requests", gitlab_project_id);
|
||||
let (items, headers) = self
|
||||
.request_with_headers::<Vec<GitLabMergeRequest>>(&path, ¶ms)
|
||||
.await?;
|
||||
|
||||
// Pagination fallback chain: Link header > x-next-page > full-page heuristic
|
||||
let link_next = parse_link_header_next(&headers);
|
||||
let x_next_page = headers
|
||||
.get("x-next-page")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
let full_page = items.len() as u32 == per_page;
|
||||
|
||||
let (next_page, is_last_page) = match (link_next.is_some(), x_next_page, full_page) {
|
||||
(true, _, _) => (Some(page + 1), false), // Link header present: continue
|
||||
(false, Some(np), _) => (Some(np), false), // x-next-page present: use it
|
||||
(false, None, true) => (Some(page + 1), false), // Full page, no headers: try next
|
||||
(false, None, false) => (None, true), // Partial page: we're done
|
||||
};
|
||||
|
||||
Ok(MergeRequestPage {
|
||||
items,
|
||||
next_page,
|
||||
is_last_page,
|
||||
})
|
||||
}
|
||||
|
||||
/// Paginate through discussions for a merge request.
|
||||
///
|
||||
/// Returns an async stream of discussions, handling pagination automatically.
|
||||
pub fn paginate_mr_discussions(
|
||||
&self,
|
||||
gitlab_project_id: i64,
|
||||
mr_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/{}/merge_requests/{}/discussions",
|
||||
gitlab_project_id, mr_iid
|
||||
);
|
||||
let result = self.request_with_headers::<Vec<GitLabDiscussion>>(&path, ¶ms).await;
|
||||
|
||||
match result {
|
||||
Ok((discussions, headers)) => {
|
||||
let is_empty = discussions.is_empty();
|
||||
let full_page = discussions.len() as u32 == per_page;
|
||||
|
||||
for discussion in discussions {
|
||||
yield Ok(discussion);
|
||||
}
|
||||
|
||||
// Pagination fallback chain: Link header > x-next-page > full-page heuristic
|
||||
let link_next = parse_link_header_next(&headers);
|
||||
let x_next_page = headers
|
||||
.get("x-next-page")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
let should_continue = match (link_next.is_some(), x_next_page, full_page) {
|
||||
(true, _, _) => true, // Link header present: continue
|
||||
(false, Some(np), _) if np > page => {
|
||||
page = np;
|
||||
true
|
||||
}
|
||||
(false, None, true) => true, // Full page, no headers: try next
|
||||
_ => false, // Otherwise we're done
|
||||
};
|
||||
|
||||
if !should_continue || is_empty {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
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,
|
||||
@@ -335,6 +515,55 @@ impl GitLabClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all discussions for an MR (collects paginated results).
|
||||
/// This is useful for parallel prefetching where we want all data upfront.
|
||||
impl GitLabClient {
|
||||
pub async fn fetch_all_mr_discussions(
|
||||
&self,
|
||||
gitlab_project_id: i64,
|
||||
mr_iid: i64,
|
||||
) -> Result<Vec<GitLabDiscussion>> {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut discussions = Vec::new();
|
||||
let mut stream = self.paginate_mr_discussions(gitlab_project_id, mr_iid);
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
discussions.push(result?);
|
||||
}
|
||||
|
||||
Ok(discussions)
|
||||
}
|
||||
}
|
||||
|
||||
/// Page result for merge request pagination.
|
||||
#[derive(Debug)]
|
||||
pub struct MergeRequestPage {
|
||||
pub items: Vec<GitLabMergeRequest>,
|
||||
pub next_page: Option<u32>,
|
||||
pub is_last_page: bool,
|
||||
}
|
||||
|
||||
/// Parse Link header to extract rel="next" URL (RFC 8288).
|
||||
fn parse_link_header_next(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("link")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|link_str| {
|
||||
// Format: <url>; rel="next", <url>; rel="last"
|
||||
for part in link_str.split(',') {
|
||||
let part = part.trim();
|
||||
if (part.contains("rel=\"next\"") || part.contains("rel=next"))
|
||||
&& let Some(start) = part.find('<')
|
||||
&& let Some(end) = part.find('>')
|
||||
{
|
||||
return Some(part[start + 1..end].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert milliseconds since epoch to ISO 8601 string.
|
||||
fn ms_to_iso8601(ms: i64) -> Option<String> {
|
||||
DateTime::<Utc>::from_timestamp_millis(ms)
|
||||
@@ -381,4 +610,52 @@ mod tests {
|
||||
// Should be 1 minute earlier
|
||||
assert_eq!(rewound, 1705312740000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_link_header_extracts_next_url() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"link",
|
||||
HeaderValue::from_static(
|
||||
r#"<https://gitlab.example.com/api/v4/projects/1/merge_requests?page=2>; rel="next", <https://gitlab.example.com/api/v4/projects/1/merge_requests?page=5>; rel="last""#,
|
||||
),
|
||||
);
|
||||
|
||||
let result = parse_link_header_next(&headers);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("https://gitlab.example.com/api/v4/projects/1/merge_requests?page=2".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_link_header_handles_unquoted_rel() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"link",
|
||||
HeaderValue::from_static(r#"<https://example.com/next>; rel=next"#),
|
||||
);
|
||||
|
||||
let result = parse_link_header_next(&headers);
|
||||
assert_eq!(result, Some("https://example.com/next".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_link_header_returns_none_when_no_next() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"link",
|
||||
HeaderValue::from_static(r#"<https://example.com/last>; rel="last""#),
|
||||
);
|
||||
|
||||
let result = parse_link_header_next(&headers);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_link_header_returns_none_when_missing() {
|
||||
let headers = HeaderMap::new();
|
||||
let result = parse_link_header_next(&headers);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user