navi_notifier_gitlab/
lib.rs1mod 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 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 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
133fn 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}