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::{
13 diff, first_sight_watermark, team_key, DiffContext, PrSnapshot, FIRST_SIGHT_LEEWAY,
14};
15use octocrab::Octocrab;
16use serde::{Deserialize, Serialize};
17use time::format_description::well_known::Rfc3339;
18use time::{Duration, OffsetDateTime};
19use tokio::sync::OnceCell;
20use tracing::{debug, warn};
21
22#[derive(Deserialize)]
24struct GithubTeam {
25 slug: String,
26 organization: GithubOrg,
27}
28
29#[derive(Deserialize)]
30struct GithubOrg {
31 login: String,
32}
33
34use crate::notification::Notification;
35
36const SOURCE_ID: &str = "github";
37const MAX_PAGES: u8 = 10;
39const NOTIF_MAX_PAGES: u8 = 30;
42const SINCE_OVERLAP: Duration = Duration::minutes(5);
44
45pub struct GitHubSourceConfig {
47 pub token: String,
48 pub api_base: Option<String>,
50 pub track_prs: bool,
52}
53
54pub struct GitHubSource {
55 octo: Octocrab,
56 viewer: OnceCell<String>,
58 track_prs: bool,
59}
60
61impl GitHubSource {
62 pub fn new(config: GitHubSourceConfig) -> Result<Self, SourceError> {
63 if config.token.trim().is_empty() {
64 return Err(SourceError::Auth(
65 "GitHub token is empty; set NAVI_GITHUB_TOKEN".into(),
66 ));
67 }
68 let mut builder = Octocrab::builder().personal_token(config.token);
69 if let Some(base) = config.api_base {
70 builder = builder
71 .base_uri(base)
72 .map_err(|e| SourceError::Request(format!("invalid api_base: {e}")))?;
73 }
74 let octo = builder
75 .build()
76 .map_err(|e| SourceError::Auth(e.to_string()))?;
77 Ok(Self {
78 octo,
79 viewer: OnceCell::new(),
80 track_prs: config.track_prs,
81 })
82 }
83
84 async fn viewer_team_keys(&self) -> HashSet<String> {
90 match self.get_all::<GithubTeam>("/user/teams").await {
91 Ok(teams) => teams
92 .into_iter()
93 .map(|t| team_key(&t.organization.login, &t.slug))
94 .collect(),
95 Err(e) => {
96 warn!(error = %e, "could not list your teams; team review requests won't be detected");
97 HashSet::new()
98 }
99 }
100 }
101
102 async fn viewer_login(&self) -> Result<&str, SourceError> {
104 self.viewer
105 .get_or_try_init(|| async {
106 let me: User = self.octo.get("/user", None::<&()>).await.map_err(map_err)?;
107 Ok::<_, SourceError>(me.login)
108 })
109 .await
110 .map(String::as_str)
111 }
112
113 async fn notifications(&self, since: Option<&str>) -> Result<Vec<Notification>, SourceError> {
115 #[derive(Serialize)]
116 struct Params<'a> {
117 all: bool,
118 per_page: u8,
119 page: u8,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 since: Option<&'a str>,
122 }
123
124 let mut out = Vec::new();
125 for page in 1..=NOTIF_MAX_PAGES {
126 let params = Params {
127 all: true,
128 per_page: 100,
129 page,
130 since,
131 };
132 let batch: Vec<Notification> = self
133 .octo
134 .get("/notifications", Some(¶ms))
135 .await
136 .map_err(map_err)?;
137 let full = batch.len() == 100;
138 out.extend(batch);
139 if !full {
140 return Ok(out);
141 }
142 if page == NOTIF_MAX_PAGES {
145 warn!(
146 fetched = out.len(),
147 cap_pages = NOTIF_MAX_PAGES,
148 "notifications truncated at the page cap; some may be missed this poll \
149 (a shorter poll interval keeps each batch smaller)"
150 );
151 }
152 }
153 Ok(out)
154 }
155
156 async fn get_all<T>(&self, path: &str) -> Result<Vec<T>, SourceError>
158 where
159 T: serde::de::DeserializeOwned,
160 {
161 #[derive(Serialize)]
162 struct Page {
163 per_page: u8,
164 page: u8,
165 }
166 let mut out = Vec::new();
167 for page in 1..=MAX_PAGES {
168 let batch: Vec<T> = self
169 .octo
170 .get(
171 path,
172 Some(&Page {
173 per_page: 100,
174 page,
175 }),
176 )
177 .await
178 .map_err(map_err)?;
179 let full = batch.len() == 100;
180 out.extend(batch);
181 if !full {
182 return Ok(out);
183 }
184 if page == MAX_PAGES {
187 warn!(%path, fetched = out.len(), "list truncated at the page cap");
188 }
189 }
190 Ok(out)
191 }
192
193 async fn fetch_pr(&self, owner: &str, repo: &str, number: u64) -> Result<PrData, SourceError> {
195 let pr: PullRequest = self
196 .octo
197 .get(format!("/repos/{owner}/{repo}/pulls/{number}"), None::<&()>)
198 .await
199 .map_err(map_err)?;
200 let reviews: Vec<Review> = self
201 .get_all(&format!("/repos/{owner}/{repo}/pulls/{number}/reviews"))
202 .await?;
203 let review_comments: Vec<ReviewComment> = self
204 .get_all(&format!("/repos/{owner}/{repo}/pulls/{number}/comments"))
205 .await?;
206 let issue_comments: Vec<IssueComment> = self
207 .get_all(&format!("/repos/{owner}/{repo}/issues/{number}/comments"))
208 .await?;
209 Ok(PrData {
210 pull_request: pr,
211 reviews,
212 review_comments,
213 issue_comments,
214 })
215 }
216
217 #[allow(clippy::too_many_arguments)]
221 async fn process_pr(
222 &self,
223 state: &dyn StateStore,
224 owner: &str,
225 repo: &str,
226 number: u64,
227 repo_url: Option<String>,
228 first_sight_since: Option<OffsetDateTime>,
229 viewer: &str,
230 viewer_teams: &HashSet<String>,
231 now: OffsetDateTime,
232 ) -> Result<Vec<Event>, SourceError> {
233 let scope = format!("{owner}/{repo}#{number}");
234 let pr_data = match self.fetch_pr(owner, repo, number).await {
235 Ok(d) => d,
236 Err(e) => {
237 warn!(%scope, error = %e, "failed to fetch PR; skipping");
239 return Ok(Vec::new());
240 }
241 };
242 let old: PrSnapshot = match state.get_snapshot(SOURCE_ID, &scope).await? {
243 Some(bytes) => serde_json::from_slice(&bytes)
244 .map_err(|e| SourceError::Parse(format!("snapshot {scope}: {e}")))?,
245 None => PrSnapshot::default(),
246 };
247 let ctx = DiffContext {
248 source_id: SOURCE_ID.to_string(),
249 viewer_login: viewer.to_string(),
250 repo: Repo {
251 owner: owner.to_string(),
252 name: repo.to_string(),
253 url: repo_url,
254 },
255 now,
256 first_sight_since,
257 viewer_teams: viewer_teams.clone(),
258 };
259 let (evs, new_snapshot) = diff(&ctx, &pr_data, &old);
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 Ok(evs)
264 }
265
266 async fn involved_open_prs(
271 &self,
272 viewer: &str,
273 ) -> Result<Vec<(String, String, u64, String)>, SourceError> {
274 #[derive(Serialize)]
275 struct Params<'a> {
276 q: &'a str,
277 per_page: u8,
278 page: u8,
279 }
280 #[derive(Deserialize)]
281 struct SearchPage {
282 items: Vec<SearchItem>,
283 }
284 #[derive(Deserialize)]
285 struct SearchItem {
286 repository_url: String,
287 number: u64,
288 updated_at: String,
289 }
290
291 let q = format!("is:open is:pr involves:{viewer}");
292 let mut out = Vec::new();
293 for page in 1..=MAX_PAGES {
294 let res: SearchPage = self
295 .octo
296 .get(
297 "/search/issues",
298 Some(&Params {
299 q: &q,
300 per_page: 100,
301 page,
302 }),
303 )
304 .await
305 .map_err(map_err)?;
306 let n = res.items.len();
307 for item in res.items {
308 if let Some((owner, repo)) = parse_repo_url(&item.repository_url) {
309 out.push((owner, repo, item.number, item.updated_at));
310 }
311 }
312 if n < 100 {
313 break;
314 }
315 if page == MAX_PAGES {
316 warn!(
317 cap = MAX_PAGES as u32 * 100,
318 "involved-PR search hit the page cap; some open PRs skipped this poll"
319 );
320 }
321 }
322 Ok(out)
323 }
324}
325
326#[async_trait]
327impl Source for GitHubSource {
328 fn id(&self) -> &str {
329 SOURCE_ID
330 }
331
332 async fn poll(&self, state: &dyn StateStore) -> Result<Vec<Event>, SourceError> {
333 let viewer = self.viewer_login().await?.to_string();
334 let viewer_teams = self.viewer_team_keys().await;
335 let poll_start = OffsetDateTime::now_utc();
336 let since = state.get_cursor(SOURCE_ID, "notif_since").await?;
337
338 let notifs = self.notifications(since.as_deref()).await?;
339 debug!(count = notifs.len(), "fetched notifications");
340
341 let mut events = Vec::new();
342 let mut processed: HashSet<String> = HashSet::new();
344
345 for n in ¬ifs {
346 if n.subject.kind != "PullRequest" {
347 continue;
348 }
349 let Some((owner, repo, number)) = n.subject.url.as_deref().and_then(parse_pr_url)
350 else {
351 warn!(url = ?n.subject.url, "could not parse PR url from notification");
352 continue;
353 };
354 let scope = format!("{owner}/{repo}#{number}");
355
356 let seen_key = format!("thread:{scope}");
362 let last_seen = state.get_cursor(SOURCE_ID, &seen_key).await?;
363 if let (Some(seen), Some(updated)) = (&last_seen, &n.updated_at) {
364 if updated.as_str() <= seen.as_str() {
365 continue;
366 }
367 }
368
369 let evs = self
370 .process_pr(
371 state,
372 &owner,
373 &repo,
374 number,
375 n.repository.html_url.clone(),
376 first_sight_watermark(n.updated_at.as_deref()),
377 &viewer,
378 &viewer_teams,
379 poll_start,
380 )
381 .await?;
382 if let Some(updated) = &n.updated_at {
383 state.put_cursor(SOURCE_ID, &seen_key, updated).await?;
384 }
385 events.extend(evs);
386 processed.insert(scope);
387 }
388
389 if self.track_prs {
394 match self.involved_open_prs(&viewer).await {
395 Ok(prs) => {
396 debug!(count = prs.len(), "fetched involved open PRs");
397 for (owner, repo, number, updated_at) in prs {
398 let scope = format!("{owner}/{repo}#{number}");
399 if processed.contains(&scope) {
400 continue;
401 }
402 let seen_key = format!("pr:{scope}");
403 if let Some(seen) = state.get_cursor(SOURCE_ID, &seen_key).await? {
404 if updated_at.as_str() <= seen.as_str() {
405 continue;
406 }
407 }
408 let evs = self
412 .process_pr(
413 state,
414 &owner,
415 &repo,
416 number,
417 Some(format!("https://github.com/{owner}/{repo}")),
418 Some(poll_start - FIRST_SIGHT_LEEWAY),
419 &viewer,
420 &viewer_teams,
421 poll_start,
422 )
423 .await?;
424 state.put_cursor(SOURCE_ID, &seen_key, &updated_at).await?;
425 events.extend(evs);
426 processed.insert(scope);
427 }
428 }
429 Err(e) => {
430 warn!(error = %e, "could not search your involved PRs; skipping that pass");
431 }
432 }
433 }
434
435 let next_since = (poll_start - SINCE_OVERLAP)
438 .format(&Rfc3339)
439 .map_err(|e| SourceError::Other(Box::new(e)))?;
440 state
441 .put_cursor(SOURCE_ID, "notif_since", &next_since)
442 .await?;
443
444 Ok(events)
445 }
446}
447
448fn parse_pr_url(url: &str) -> Option<(String, String, u64)> {
450 let after = url.split("/repos/").nth(1)?;
451 let mut parts = after.split('/');
452 let owner = parts.next()?.to_string();
453 let repo = parts.next()?.to_string();
454 let kind = parts.next()?; if kind != "pulls" {
456 return None;
457 }
458 let number: u64 = parts.next()?.parse().ok()?;
459 Some((owner, repo, number))
460}
461
462fn parse_repo_url(url: &str) -> Option<(String, String)> {
465 let after = url.split("/repos/").nth(1)?;
466 let mut parts = after.split('/');
467 let owner = parts.next().filter(|s| !s.is_empty())?.to_string();
468 let repo = parts.next().filter(|s| !s.is_empty())?.to_string();
469 Some((owner, repo))
470}
471
472fn map_err(err: octocrab::Error) -> SourceError {
473 classify_github_error(&err.to_string())
474}
475
476fn classify_github_error(msg: &str) -> SourceError {
480 let lower = msg.to_ascii_lowercase();
481 if lower.contains("rate limit") {
482 SourceError::RateLimited {
483 retry_after_secs: 60,
484 }
485 } else if lower.contains("bad credentials")
486 || lower.contains("unauthorized")
487 || lower.contains("401")
488 {
489 SourceError::Auth(format!("invalid GitHub token: {msg}"))
490 } else if lower.contains("forbidden")
491 || lower.contains("resource not accessible")
492 || lower.contains("403")
493 {
494 SourceError::Auth(format!(
495 "GitHub returned 403 (forbidden); the token likely lacks required scopes \
496 (notifications + repo/PR read): {msg}"
497 ))
498 } else {
499 SourceError::Request(msg.to_string())
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::{classify_github_error, parse_pr_url, parse_repo_url};
506 use navi_notifier_core::SourceError;
507
508 #[test]
509 fn parse_repo_url_extracts_owner_and_repo() {
510 assert_eq!(
511 parse_repo_url("https://api.github.com/repos/acme/widgets"),
512 Some(("acme".into(), "widgets".into()))
513 );
514 assert_eq!(parse_repo_url("https://api.github.com/user"), None);
515 assert_eq!(parse_repo_url("nonsense"), None);
516 }
517
518 #[test]
519 fn rate_limit_messages_are_rate_limited() {
520 assert!(matches!(
521 classify_github_error("API rate limit exceeded for 1.2.3.4"),
522 SourceError::RateLimited { .. }
523 ));
524 assert!(matches!(
525 classify_github_error("You have exceeded a secondary rate limit"),
526 SourceError::RateLimited { .. }
527 ));
528 }
529
530 #[test]
531 fn bad_credentials_is_auth() {
532 assert!(matches!(
533 classify_github_error("Bad credentials"),
534 SourceError::Auth(_)
535 ));
536 }
537
538 #[test]
539 fn forbidden_is_auth_not_rate_limited() {
540 match classify_github_error("Resource not accessible by personal access token") {
541 SourceError::Auth(m) => assert!(m.contains("403")),
542 other => panic!("expected Auth, got {other:?}"),
543 }
544 }
545
546 #[test]
547 fn other_errors_are_request() {
548 assert!(matches!(
549 classify_github_error("connection reset by peer"),
550 SourceError::Request(_)
551 ));
552 }
553
554 #[test]
555 fn parses_pr_url() {
556 assert_eq!(
557 parse_pr_url("https://api.github.com/repos/acme/widgets/pulls/12"),
558 Some(("acme".into(), "widgets".into(), 12))
559 );
560 }
561
562 #[test]
563 fn rejects_non_pull_urls() {
564 assert_eq!(
565 parse_pr_url("https://api.github.com/repos/acme/widgets/issues/12"),
566 None
567 );
568 }
569}