Skip to main content

navi_notifier_gitlab/
lib.rs

1//! GitLab source for navi.
2//!
3//! GitLab's Todos API (`GET /todos`) is already a per-user action feed with a
4//! specific `action_name`, so this maps pending todos straight to normalized
5//! events, no per-MR timeline diff needed. It covers review requests, approval
6//! requests, and mentions on merge requests. Merge/close and reply-to-your-comment
7//! events need MR-note diffing and are a follow-up (see SCRATCHPAD).
8
9mod api;
10
11use async_trait::async_trait;
12use navi_notifier_core::model::{Actor, Event, EventKind, PullRequest, Repo, ViewerRelationship};
13use navi_notifier_core::traits::{Source, StateStore};
14use navi_notifier_core::SourceError;
15use serde::Serialize;
16use time::format_description::well_known::Rfc3339;
17use time::OffsetDateTime;
18use tokio::sync::OnceCell;
19use tracing::debug;
20
21use api::{Todo, User};
22
23const SOURCE_ID: &str = "gitlab";
24const DEFAULT_API_BASE: &str = "https://gitlab.com/api/v4";
25const MAX_PAGES: u8 = 10;
26
27pub struct GitLabSourceConfig {
28    pub token: String,
29    /// API base, e.g. `https://gitlab.example.com/api/v4` for self-hosted.
30    pub api_base: Option<String>,
31}
32
33pub struct GitLabSource {
34    client: reqwest::Client,
35    token: String,
36    api_base: String,
37    viewer: OnceCell<String>,
38}
39
40impl GitLabSource {
41    pub fn new(config: GitLabSourceConfig) -> Result<Self, SourceError> {
42        if config.token.trim().is_empty() {
43            return Err(SourceError::Auth(
44                "GitLab token is empty; set NAVI_GITLAB_TOKEN".into(),
45            ));
46        }
47        let client = reqwest::Client::builder()
48            .timeout(std::time::Duration::from_secs(30))
49            .build()
50            .map_err(|e| SourceError::Request(format!("building HTTP client: {e}")))?;
51        Ok(Self {
52            client,
53            token: config.token,
54            api_base: config
55                .api_base
56                .unwrap_or_else(|| DEFAULT_API_BASE.to_string()),
57            viewer: OnceCell::new(),
58        })
59    }
60
61    async fn viewer_login(&self) -> Result<&str, SourceError> {
62        self.viewer
63            .get_or_try_init(|| async {
64                let me: User = self.get("/user", 1).await?;
65                Ok::<_, SourceError>(me.username)
66            })
67            .await
68            .map(String::as_str)
69    }
70
71    async fn get<T: for<'de> serde::Deserialize<'de>>(
72        &self,
73        path: &str,
74        page: u8,
75    ) -> Result<T, SourceError> {
76        #[derive(Serialize)]
77        struct Params {
78            per_page: u8,
79            page: u8,
80        }
81        let resp = self
82            .client
83            .get(format!("{}{path}", self.api_base))
84            .header("PRIVATE-TOKEN", &self.token)
85            .query(&Params {
86                per_page: 100,
87                page,
88            })
89            .send()
90            .await
91            .map_err(|e| SourceError::Request(e.to_string()))?;
92        map_status(&resp)?;
93        resp.json()
94            .await
95            .map_err(|e| SourceError::Parse(e.to_string()))
96    }
97
98    /// Fetch pending todos across pages.
99    async fn todos(&self) -> Result<Vec<Todo>, SourceError> {
100        let mut out = Vec::new();
101        for page in 1..=MAX_PAGES {
102            let batch: Vec<Todo> = self.get("/todos?state=pending", page).await?;
103            let n = batch.len();
104            out.extend(batch);
105            if n < 100 {
106                break;
107            }
108        }
109        Ok(out)
110    }
111}
112
113#[async_trait]
114impl Source for GitLabSource {
115    fn id(&self) -> &str {
116        SOURCE_ID
117    }
118
119    async fn poll(&self, _state: &dyn StateStore) -> Result<Vec<Event>, SourceError> {
120        let viewer = self.viewer_login().await?.to_string();
121        let now = OffsetDateTime::now_utc();
122        let todos = self.todos().await?;
123        debug!(count = todos.len(), "fetched gitlab todos");
124
125        let events = todos
126            .iter()
127            .filter_map(|todo| todo_to_event(todo, &viewer, now))
128            .collect();
129        Ok(events)
130    }
131}
132
133/// Map a pending todo to a normalized event, or `None` if it isn't one we surface.
134/// Pure and unit-tested; `now` is the fallback timestamp.
135fn todo_to_event(todo: &Todo, viewer: &str, now: OffsetDateTime) -> Option<Event> {
136    if todo.target_type != "MergeRequest" {
137        return None;
138    }
139    let target = todo.target.as_ref()?;
140
141    let kind = match todo.action_name.as_str() {
142        "review_requested" | "approval_required" | "assigned" => EventKind::ReviewRequested,
143        "mentioned" | "directly_addressed" => EventKind::Mentioned,
144        _ => return None,
145    };
146
147    let (owner, name) = match todo.project.path_with_namespace.rsplit_once('/') {
148        Some((o, n)) => (o.to_string(), n.to_string()),
149        None => (String::new(), todo.project.path_with_namespace.clone()),
150    };
151    let repo = Repo {
152        owner,
153        name,
154        url: todo.project.web_url.clone(),
155    };
156
157    let author = target
158        .author
159        .as_ref()
160        .map(|u| Actor::new(u.username.as_str()))
161        .unwrap_or_else(|| Actor::new("unknown"));
162    let is_author = author.login.eq_ignore_ascii_case(viewer);
163
164    let occurred = todo
165        .created_at
166        .as_deref()
167        .and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok())
168        .unwrap_or(now);
169
170    let url = todo
171        .target_url
172        .clone()
173        .or_else(|| target.web_url.clone())
174        .unwrap_or_default();
175
176    Some(Event {
177        source_id: SOURCE_ID.to_string(),
178        kind,
179        pull_request: PullRequest {
180            repo: repo.clone(),
181            number: target.iid,
182            title: target.title.clone(),
183            url,
184            author,
185            draft: target.is_draft(),
186        },
187        viewer: ViewerRelationship {
188            is_author,
189            is_reviewer: true,
190        },
191        actor: todo
192            .author
193            .as_ref()
194            .map(|u| Actor::new(u.username.as_str()))
195            .unwrap_or_else(|| Actor::new("unknown")),
196        occurred_at: occurred,
197        target_url: Some(target.web_url.clone().unwrap_or_default()),
198        excerpt: todo.body.clone().filter(|b| !b.is_empty()),
199        dedup_key: Event::make_dedup_key(
200            SOURCE_ID,
201            &repo,
202            target.iid,
203            &format!("todo:{}", todo.id),
204        ),
205    })
206}
207
208fn map_status(resp: &reqwest::Response) -> Result<(), SourceError> {
209    let status = resp.status();
210    if status.is_success() {
211        return Ok(());
212    }
213    match status.as_u16() {
214        401 => Err(SourceError::Auth("invalid GitLab token".into())),
215        403 => Err(SourceError::Auth(
216            "GitLab returned 403; token likely lacks read_api scope".into(),
217        )),
218        429 => Err(SourceError::RateLimited {
219            retry_after_secs: 60,
220        }),
221        _ => Err(SourceError::Request(format!("gitlab returned {status}"))),
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    include!("todo_tests.rs");
228}