Skip to main content

navi_notifier_github/
source.rs

1//! The GitHub [`Source`]: turns notifications into fetches, fetches into diffs,
2//! and diffs into normalized events. All GitHub-specific I/O lives here; the
3//! decision logic lives in the pure `navi-notifier-forge` diff engine.
4
5use 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/// A team from `GET /user/teams`, reduced to what we need to match team requests.
23#[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";
37/// Safety cap on pagination per endpoint so a pathological PR can't stall a poll.
38const MAX_PAGES: u8 = 10;
39/// Notifications page deeper than per-PR sub-resources: a single poll after a long
40/// gap (or the very first, before a `since` cursor exists) can span many pages.
41const NOTIF_MAX_PAGES: u8 = 30;
42/// Overlap window when advancing the `since` cursor, to tolerate clock skew.
43const SINCE_OVERLAP: Duration = Duration::minutes(5);
44
45/// Configuration for the GitHub source.
46pub struct GitHubSourceConfig {
47    pub token: String,
48    /// API base for GitHub Enterprise Server (e.g. `https://ghe.example.com/api/v3`).
49    pub api_base: Option<String>,
50    /// Poll your involved open PRs directly (search), on top of notifications.
51    pub track_prs: bool,
52}
53
54pub struct GitHubSource {
55    octo: Octocrab,
56    /// Cached authenticated login, resolved lazily on first poll.
57    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    /// The viewer's team memberships as `"org/slug"` keys, for matching team review
85    /// requests. Fetched every poll (not cached like the login) so joining or
86    /// leaving a team is picked up without a restart; it's one cheap request.
87    /// Best effort: if the token can't list teams (needs `read:org`), team requests
88    /// just won't be detected.
89    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    /// The authenticated user's login, fetched once and cached.
103    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    /// List notifications updated since `since` (RFC3339), across pages.
114    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(&params))
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            // Last page still full at the cap: more remain that we won't fetch
143            // this pass. Surface it rather than dropping silently.
144            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    /// Fetch a page-collected list from a repo sub-resource path.
157    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            // Last page still full at the cap: a PR with a huge review/comment
185            // history is truncated. Rare, but surface it rather than drop silently.
186            if page == MAX_PAGES {
187                warn!(%path, fetched = out.len(), "list truncated at the page cap");
188            }
189        }
190        Ok(out)
191    }
192
193    /// Fetch everything the diff needs for one PR.
194    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    /// Fetch, diff, and persist one PR against its stored snapshot; returns the
218    /// events. Shared by the notifications and involved-PR paths so both dedupe
219    /// through the same snapshot key and `dedup_key`s.
220    #[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                // One inaccessible PR (deleted, perms) shouldn't abort the poll.
238                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    /// Open PRs the viewer is involved in (author, reviewer, assignee, commenter,
267    /// mentioned), via search - independent of notification settings, so it still
268    /// finds activity on muted repos and reviews on your own PRs that GitHub never
269    /// puts in your notifications. Returns `(owner, repo, number, updated_at)`.
270    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        // Scopes handled this poll, so the involved-PR pass doesn't re-process one.
343        let mut processed: HashSet<String> = HashSet::new();
344
345        for n in &notifs {
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            // Skip threads whose notification hasn't advanced. An optimisation
357            // only; the snapshot would suppress duplicates anyway. Do NOT mark the
358            // scope processed here: a notification thread doesn't always advance
359            // when the PR itself changes (bare approvals, activity on your own
360            // PRs), and blocking the involved-PR pass would reintroduce that miss.
361            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        // Involved open PRs, independent of the notifications inbox: catches
390        // reviews on your own PRs and activity in muted repos that GitHub never
391        // surfaces as a notification. A per-PR cursor keeps it cheap (only PRs
392        // whose `updated_at` advanced get fetched).
393        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                        // No triggering notification here to anchor first sight, so
409                        // surface only activity from the last leeway window; older
410                        // history is baselined silently (no first-run backlog).
411                        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        // Advance the list cursor with a small overlap so nothing straddling the
436        // boundary is missed on the next poll.
437        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
448/// Parse `https://api.github.com/repos/{owner}/{repo}/pulls/{number}` into parts.
449fn 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()?; // "pulls"
455    if kind != "pulls" {
456        return None;
457    }
458    let number: u64 = parts.next()?.parse().ok()?;
459    Some((owner, repo, number))
460}
461
462/// Parse `https://api.github.com/repos/{owner}/{repo}` (a search item's
463/// `repository_url`) into `(owner, repo)`.
464fn 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
476/// Classify a GitHub error message. A 403 is only a rate limit when the message
477/// says so (an unauthenticated or over-quota call); a plain 403 is a permission
478/// problem, not something to silently retry.
479fn 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}