1use std::collections::HashSet;
6
7use async_trait::async_trait;
8use navi_notifier_core::model::{Event, Repo};
9use navi_notifier_core::traits::{Source, StateStore};
10use navi_notifier_core::SourceError;
11use navi_notifier_forge::model::{IssueComment, PrData, PullRequest, Review, ReviewComment, User};
12use navi_notifier_forge::{diff, first_sight_watermark, team_key, DiffContext, PrSnapshot};
13use octocrab::Octocrab;
14use serde::{Deserialize, Serialize};
15use time::format_description::well_known::Rfc3339;
16use time::{Duration, OffsetDateTime};
17use tokio::sync::OnceCell;
18use tracing::{debug, warn};
19
20#[derive(Deserialize)]
22struct GithubTeam {
23 slug: String,
24 organization: GithubOrg,
25}
26
27#[derive(Deserialize)]
28struct GithubOrg {
29 login: String,
30}
31
32use crate::notification::Notification;
33
34const SOURCE_ID: &str = "github";
35const MAX_PAGES: u8 = 10;
37const SINCE_OVERLAP: Duration = Duration::minutes(5);
39
40pub struct GitHubSourceConfig {
42 pub token: String,
43 pub api_base: Option<String>,
45}
46
47pub struct GitHubSource {
48 octo: Octocrab,
49 viewer: OnceCell<String>,
51}
52
53impl GitHubSource {
54 pub fn new(config: GitHubSourceConfig) -> Result<Self, SourceError> {
55 if config.token.trim().is_empty() {
56 return Err(SourceError::Auth(
57 "GitHub token is empty; set NAVI_GITHUB_TOKEN".into(),
58 ));
59 }
60 let mut builder = Octocrab::builder().personal_token(config.token);
61 if let Some(base) = config.api_base {
62 builder = builder
63 .base_uri(base)
64 .map_err(|e| SourceError::Request(format!("invalid api_base: {e}")))?;
65 }
66 let octo = builder
67 .build()
68 .map_err(|e| SourceError::Auth(e.to_string()))?;
69 Ok(Self {
70 octo,
71 viewer: OnceCell::new(),
72 })
73 }
74
75 async fn viewer_team_keys(&self) -> HashSet<String> {
81 match self.get_all::<GithubTeam>("/user/teams").await {
82 Ok(teams) => teams
83 .into_iter()
84 .map(|t| team_key(&t.organization.login, &t.slug))
85 .collect(),
86 Err(e) => {
87 warn!(error = %e, "could not list your teams; team review requests won't be detected");
88 HashSet::new()
89 }
90 }
91 }
92
93 async fn viewer_login(&self) -> Result<&str, SourceError> {
95 self.viewer
96 .get_or_try_init(|| async {
97 let me: User = self.octo.get("/user", None::<&()>).await.map_err(map_err)?;
98 Ok::<_, SourceError>(me.login)
99 })
100 .await
101 .map(String::as_str)
102 }
103
104 async fn notifications(&self, since: Option<&str>) -> Result<Vec<Notification>, SourceError> {
106 #[derive(Serialize)]
107 struct Params<'a> {
108 all: bool,
109 per_page: u8,
110 page: u8,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 since: Option<&'a str>,
113 }
114
115 let mut out = Vec::new();
116 for page in 1..=MAX_PAGES {
117 let params = Params {
118 all: true,
119 per_page: 100,
120 page,
121 since,
122 };
123 let batch: Vec<Notification> = self
124 .octo
125 .get("/notifications", Some(¶ms))
126 .await
127 .map_err(map_err)?;
128 let n = batch.len();
129 out.extend(batch);
130 if n < 100 {
131 break;
132 }
133 }
134 Ok(out)
135 }
136
137 async fn get_all<T>(&self, path: &str) -> Result<Vec<T>, SourceError>
139 where
140 T: serde::de::DeserializeOwned,
141 {
142 #[derive(Serialize)]
143 struct Page {
144 per_page: u8,
145 page: u8,
146 }
147 let mut out = Vec::new();
148 for page in 1..=MAX_PAGES {
149 let batch: Vec<T> = self
150 .octo
151 .get(
152 path,
153 Some(&Page {
154 per_page: 100,
155 page,
156 }),
157 )
158 .await
159 .map_err(map_err)?;
160 let n = batch.len();
161 out.extend(batch);
162 if n < 100 {
163 break;
164 }
165 }
166 Ok(out)
167 }
168
169 async fn fetch_pr(&self, owner: &str, repo: &str, number: u64) -> Result<PrData, SourceError> {
171 let pr: PullRequest = self
172 .octo
173 .get(format!("/repos/{owner}/{repo}/pulls/{number}"), None::<&()>)
174 .await
175 .map_err(map_err)?;
176 let reviews: Vec<Review> = self
177 .get_all(&format!("/repos/{owner}/{repo}/pulls/{number}/reviews"))
178 .await?;
179 let review_comments: Vec<ReviewComment> = self
180 .get_all(&format!("/repos/{owner}/{repo}/pulls/{number}/comments"))
181 .await?;
182 let issue_comments: Vec<IssueComment> = self
183 .get_all(&format!("/repos/{owner}/{repo}/issues/{number}/comments"))
184 .await?;
185 Ok(PrData {
186 pull_request: pr,
187 reviews,
188 review_comments,
189 issue_comments,
190 })
191 }
192}
193
194#[async_trait]
195impl Source for GitHubSource {
196 fn id(&self) -> &str {
197 SOURCE_ID
198 }
199
200 async fn poll(&self, state: &dyn StateStore) -> Result<Vec<Event>, SourceError> {
201 let viewer = self.viewer_login().await?.to_string();
202 let viewer_teams = self.viewer_team_keys().await;
203 let poll_start = OffsetDateTime::now_utc();
204 let since = state.get_cursor(SOURCE_ID, "notif_since").await?;
205
206 let notifs = self.notifications(since.as_deref()).await?;
207 debug!(count = notifs.len(), "fetched notifications");
208
209 let mut events = Vec::new();
210 for n in ¬ifs {
211 if n.subject.kind != "PullRequest" {
212 continue;
213 }
214 let Some((owner, repo, number)) = n.subject.url.as_deref().and_then(parse_pr_url)
215 else {
216 warn!(url = ?n.subject.url, "could not parse PR url from notification");
217 continue;
218 };
219 let scope = format!("{owner}/{repo}#{number}");
220
221 let seen_key = format!("thread:{scope}");
224 let last_seen = state.get_cursor(SOURCE_ID, &seen_key).await?;
225 if let (Some(seen), Some(updated)) = (&last_seen, &n.updated_at) {
226 if updated.as_str() <= seen.as_str() {
227 continue;
228 }
229 }
230
231 let pr_data = match self.fetch_pr(&owner, &repo, number).await {
232 Ok(d) => d,
233 Err(e) => {
234 warn!(%scope, error = %e, "failed to fetch PR; skipping");
236 continue;
237 }
238 };
239
240 let old: PrSnapshot = match state.get_snapshot(SOURCE_ID, &scope).await? {
241 Some(bytes) => serde_json::from_slice(&bytes)
242 .map_err(|e| SourceError::Parse(format!("snapshot {scope}: {e}")))?,
243 None => PrSnapshot::default(),
244 };
245
246 let ctx = DiffContext {
247 source_id: SOURCE_ID.to_string(),
248 viewer_login: viewer.clone(),
249 repo: Repo {
250 owner: owner.clone(),
251 name: repo.clone(),
252 url: n.repository.html_url.clone(),
253 },
254 now: poll_start,
255 first_sight_since: first_sight_watermark(n.updated_at.as_deref()),
256 viewer_teams: viewer_teams.clone(),
257 };
258 let (evs, new_snapshot) = diff(&ctx, &pr_data, &old);
259
260 let bytes = serde_json::to_vec(&new_snapshot)
261 .map_err(|e| SourceError::Parse(format!("serialize snapshot {scope}: {e}")))?;
262 state.put_snapshot(SOURCE_ID, &scope, &bytes).await?;
263 if let Some(updated) = &n.updated_at {
264 state.put_cursor(SOURCE_ID, &seen_key, updated).await?;
265 }
266
267 events.extend(evs);
268 }
269
270 let next_since = (poll_start - SINCE_OVERLAP)
273 .format(&Rfc3339)
274 .map_err(|e| SourceError::Other(Box::new(e)))?;
275 state
276 .put_cursor(SOURCE_ID, "notif_since", &next_since)
277 .await?;
278
279 Ok(events)
280 }
281}
282
283fn parse_pr_url(url: &str) -> Option<(String, String, u64)> {
285 let after = url.split("/repos/").nth(1)?;
286 let mut parts = after.split('/');
287 let owner = parts.next()?.to_string();
288 let repo = parts.next()?.to_string();
289 let kind = parts.next()?; if kind != "pulls" {
291 return None;
292 }
293 let number: u64 = parts.next()?.parse().ok()?;
294 Some((owner, repo, number))
295}
296
297fn map_err(err: octocrab::Error) -> SourceError {
298 classify_github_error(&err.to_string())
299}
300
301fn classify_github_error(msg: &str) -> SourceError {
305 let lower = msg.to_ascii_lowercase();
306 if lower.contains("rate limit") {
307 SourceError::RateLimited {
308 retry_after_secs: 60,
309 }
310 } else if lower.contains("bad credentials")
311 || lower.contains("unauthorized")
312 || lower.contains("401")
313 {
314 SourceError::Auth(format!("invalid GitHub token: {msg}"))
315 } else if lower.contains("forbidden")
316 || lower.contains("resource not accessible")
317 || lower.contains("403")
318 {
319 SourceError::Auth(format!(
320 "GitHub returned 403 (forbidden); the token likely lacks required scopes \
321 (notifications + repo/PR read): {msg}"
322 ))
323 } else {
324 SourceError::Request(msg.to_string())
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::{classify_github_error, parse_pr_url};
331 use navi_notifier_core::SourceError;
332
333 #[test]
334 fn rate_limit_messages_are_rate_limited() {
335 assert!(matches!(
336 classify_github_error("API rate limit exceeded for 1.2.3.4"),
337 SourceError::RateLimited { .. }
338 ));
339 assert!(matches!(
340 classify_github_error("You have exceeded a secondary rate limit"),
341 SourceError::RateLimited { .. }
342 ));
343 }
344
345 #[test]
346 fn bad_credentials_is_auth() {
347 assert!(matches!(
348 classify_github_error("Bad credentials"),
349 SourceError::Auth(_)
350 ));
351 }
352
353 #[test]
354 fn forbidden_is_auth_not_rate_limited() {
355 match classify_github_error("Resource not accessible by personal access token") {
356 SourceError::Auth(m) => assert!(m.contains("403")),
357 other => panic!("expected Auth, got {other:?}"),
358 }
359 }
360
361 #[test]
362 fn other_errors_are_request() {
363 assert!(matches!(
364 classify_github_error("connection reset by peer"),
365 SourceError::Request(_)
366 ));
367 }
368
369 #[test]
370 fn parses_pr_url() {
371 assert_eq!(
372 parse_pr_url("https://api.github.com/repos/acme/widgets/pulls/12"),
373 Some(("acme".into(), "widgets".into(), 12))
374 );
375 }
376
377 #[test]
378 fn rejects_non_pull_urls() {
379 assert_eq!(
380 parse_pr_url("https://api.github.com/repos/acme/widgets/issues/12"),
381 None
382 );
383 }
384}