From e73d2907dcb5ad13d57a95ab6de79ba3cd4a6829 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Tue, 3 Feb 2026 12:07:19 -0500 Subject: [PATCH] feat(client): Add Resource Events API endpoints with generic paginated fetcher Extends GitLabClient with methods for fetching resource events from GitLab's per-entity API endpoints. Adds a new impl block containing: - fetch_all_pages: Generic paginated collector that handles x-next-page header parsing with fallback to page-size heuristics. Uses per_page=100 and respects the existing rate limiter via request_with_headers. Terminates when: (a) x-next-page header is absent/stale, (b) response is empty, or (c) page is not full. - Six typed endpoint methods: - fetch_issue_state_events / fetch_mr_state_events - fetch_issue_label_events / fetch_mr_label_events - fetch_issue_milestone_events / fetch_mr_milestone_events - fetch_all_resource_events: Convenience method that fetches all three event types for an entity (issue or merge_request) in sequence, returning a tuple of (state, label, milestone) event vectors. Routes to issue or MR endpoints based on entity_type string. All methods follow the existing client patterns: path formatting with gitlab_project_id and iid, error propagation via Result, and rate limiter integration through the shared request_with_headers path. Co-Authored-By: Claude Opus 4.5 --- src/gitlab/client.rs | 149 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 1 deletion(-) diff --git a/src/gitlab/client.rs b/src/gitlab/client.rs index 5175c2e..095e16e 100644 --- a/src/gitlab/client.rs +++ b/src/gitlab/client.rs @@ -13,7 +13,8 @@ use tokio::time::sleep; use tracing::debug; use super::types::{ - GitLabDiscussion, GitLabIssue, GitLabMergeRequest, GitLabProject, GitLabUser, GitLabVersion, + GitLabDiscussion, GitLabIssue, GitLabLabelEvent, GitLabMergeRequest, GitLabMilestoneEvent, + GitLabProject, GitLabStateEvent, GitLabUser, GitLabVersion, }; use crate::core::error::{LoreError, Result}; @@ -550,6 +551,152 @@ impl GitLabClient { } } +/// Resource events API methods. +/// +/// These endpoints return per-entity events (not project-wide), so they collect +/// all pages into a Vec rather than using streaming. +impl GitLabClient { + /// Fetch all pages from a paginated endpoint, returning collected results. + async fn fetch_all_pages( + &self, + path: &str, + ) -> Result> { + let mut results = Vec::new(); + let mut page = 1u32; + let per_page = 100u32; + + loop { + let params = vec![ + ("per_page", per_page.to_string()), + ("page", page.to_string()), + ]; + + let (items, headers) = self + .request_with_headers::>(path, ¶ms) + .await?; + + let is_empty = items.is_empty(); + let full_page = items.len() as u32 == per_page; + results.extend(items); + + 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 || !full_page { + break; + } + page += 1; + } + } + } + + Ok(results) + } + + /// Fetch state events for an issue. + pub async fn fetch_issue_state_events( + &self, + gitlab_project_id: i64, + iid: i64, + ) -> Result> { + let path = format!( + "/api/v4/projects/{gitlab_project_id}/issues/{iid}/resource_state_events" + ); + self.fetch_all_pages(&path).await + } + + /// Fetch label events for an issue. + pub async fn fetch_issue_label_events( + &self, + gitlab_project_id: i64, + iid: i64, + ) -> Result> { + let path = format!( + "/api/v4/projects/{gitlab_project_id}/issues/{iid}/resource_label_events" + ); + self.fetch_all_pages(&path).await + } + + /// Fetch milestone events for an issue. + pub async fn fetch_issue_milestone_events( + &self, + gitlab_project_id: i64, + iid: i64, + ) -> Result> { + let path = format!( + "/api/v4/projects/{gitlab_project_id}/issues/{iid}/resource_milestone_events" + ); + self.fetch_all_pages(&path).await + } + + /// Fetch state events for a merge request. + pub async fn fetch_mr_state_events( + &self, + gitlab_project_id: i64, + iid: i64, + ) -> Result> { + let path = format!( + "/api/v4/projects/{gitlab_project_id}/merge_requests/{iid}/resource_state_events" + ); + self.fetch_all_pages(&path).await + } + + /// Fetch label events for a merge request. + pub async fn fetch_mr_label_events( + &self, + gitlab_project_id: i64, + iid: i64, + ) -> Result> { + let path = format!( + "/api/v4/projects/{gitlab_project_id}/merge_requests/{iid}/resource_label_events" + ); + self.fetch_all_pages(&path).await + } + + /// Fetch milestone events for a merge request. + pub async fn fetch_mr_milestone_events( + &self, + gitlab_project_id: i64, + iid: i64, + ) -> Result> { + let path = format!( + "/api/v4/projects/{gitlab_project_id}/merge_requests/{iid}/resource_milestone_events" + ); + self.fetch_all_pages(&path).await + } + + /// Fetch all three event types for an entity in one call. + pub async fn fetch_all_resource_events( + &self, + gitlab_project_id: i64, + entity_type: &str, + iid: i64, + ) -> Result<(Vec, Vec, Vec)> { + match entity_type { + "issue" => { + let state = self.fetch_issue_state_events(gitlab_project_id, iid).await?; + let label = self.fetch_issue_label_events(gitlab_project_id, iid).await?; + let milestone = self.fetch_issue_milestone_events(gitlab_project_id, iid).await?; + Ok((state, label, milestone)) + } + "merge_request" => { + let state = self.fetch_mr_state_events(gitlab_project_id, iid).await?; + let label = self.fetch_mr_label_events(gitlab_project_id, iid).await?; + let milestone = self.fetch_mr_milestone_events(gitlab_project_id, iid).await?; + Ok((state, label, milestone)) + } + _ => Err(LoreError::Other(format!( + "Invalid entity type for resource events: {entity_type}" + ))), + } + } +} + /// Page result for merge request pagination. #[derive(Debug)] pub struct MergeRequestPage {