1use async_trait::async_trait;
6use navi_notifier_core::model::{Event, Repo};
7use navi_notifier_core::traits::{Source, StateStore};
8use navi_notifier_core::SourceError;
9use octocrab::Octocrab;
10use serde::Serialize;
11use time::format_description::well_known::Rfc3339;
12use time::{Duration, OffsetDateTime};
13use tokio::sync::OnceCell;
14use tracing::{debug, warn};
15
16use crate::api::{IssueComment, Notification, PrData, PullRequest, Review, ReviewComment, User};
17use crate::diff::{diff, DiffContext};
18use crate::snapshot::PrSnapshot;
19
20const SOURCE_ID: &str = "github";
21const MAX_PAGES: u8 = 10;
23const SINCE_OVERLAP: Duration = Duration::minutes(5);
25
26pub struct GitHubSourceConfig {
28 pub token: String,
29 pub api_base: Option<String>,
31}
32
33pub struct GitHubSource {
34 octo: Octocrab,
35 viewer: OnceCell<String>,
37}
38
39impl GitHubSource {
40 pub fn new(config: GitHubSourceConfig) -> Result<Self, SourceError> {
41 let mut builder = Octocrab::builder().personal_token(config.token);
42 if let Some(base) = config.api_base {
43 builder = builder
44 .base_uri(base)
45 .map_err(|e| SourceError::Request(format!("invalid api_base: {e}")))?;
46 }
47 let octo = builder
48 .build()
49 .map_err(|e| SourceError::Auth(e.to_string()))?;
50 Ok(Self {
51 octo,
52 viewer: OnceCell::new(),
53 })
54 }
55
56 async fn viewer_login(&self) -> Result<&str, SourceError> {
58 self.viewer
59 .get_or_try_init(|| async {
60 let me: User = self.octo.get("/user", None::<&()>).await.map_err(map_err)?;
61 Ok::<_, SourceError>(me.login)
62 })
63 .await
64 .map(String::as_str)
65 }
66
67 async fn notifications(&self, since: Option<&str>) -> Result<Vec<Notification>, SourceError> {
69 #[derive(Serialize)]
70 struct Params<'a> {
71 all: bool,
72 per_page: u8,
73 page: u8,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 since: Option<&'a str>,
76 }
77
78 let mut out = Vec::new();
79 for page in 1..=MAX_PAGES {
80 let params = Params {
81 all: true,
82 per_page: 100,
83 page,
84 since,
85 };
86 let batch: Vec<Notification> = self
87 .octo
88 .get("/notifications", Some(¶ms))
89 .await
90 .map_err(map_err)?;
91 let n = batch.len();
92 out.extend(batch);
93 if n < 100 {
94 break;
95 }
96 }
97 Ok(out)
98 }
99
100 async fn get_all<T>(&self, path: &str) -> Result<Vec<T>, SourceError>
102 where
103 T: serde::de::DeserializeOwned,
104 {
105 #[derive(Serialize)]
106 struct Page {
107 per_page: u8,
108 page: u8,
109 }
110 let mut out = Vec::new();
111 for page in 1..=MAX_PAGES {
112 let batch: Vec<T> = self
113 .octo
114 .get(
115 path,
116 Some(&Page {
117 per_page: 100,
118 page,
119 }),
120 )
121 .await
122 .map_err(map_err)?;
123 let n = batch.len();
124 out.extend(batch);
125 if n < 100 {
126 break;
127 }
128 }
129 Ok(out)
130 }
131
132 async fn fetch_pr(&self, owner: &str, repo: &str, number: u64) -> Result<PrData, SourceError> {
134 let pr: PullRequest = self
135 .octo
136 .get(format!("/repos/{owner}/{repo}/pulls/{number}"), None::<&()>)
137 .await
138 .map_err(map_err)?;
139 let reviews: Vec<Review> = self
140 .get_all(&format!("/repos/{owner}/{repo}/pulls/{number}/reviews"))
141 .await?;
142 let review_comments: Vec<ReviewComment> = self
143 .get_all(&format!("/repos/{owner}/{repo}/pulls/{number}/comments"))
144 .await?;
145 let issue_comments: Vec<IssueComment> = self
146 .get_all(&format!("/repos/{owner}/{repo}/issues/{number}/comments"))
147 .await?;
148 Ok(PrData {
149 pull_request: pr,
150 reviews,
151 review_comments,
152 issue_comments,
153 })
154 }
155}
156
157#[async_trait]
158impl Source for GitHubSource {
159 fn id(&self) -> &str {
160 SOURCE_ID
161 }
162
163 async fn poll(&self, state: &dyn StateStore) -> Result<Vec<Event>, SourceError> {
164 let viewer = self.viewer_login().await?.to_string();
165 let poll_start = OffsetDateTime::now_utc();
166 let since = state.get_cursor(SOURCE_ID, "notif_since").await?;
167
168 let notifs = self.notifications(since.as_deref()).await?;
169 debug!(count = notifs.len(), "fetched notifications");
170
171 let mut events = Vec::new();
172 for n in ¬ifs {
173 if n.subject.kind != "PullRequest" {
174 continue;
175 }
176 let Some((owner, repo, number)) = n.subject.url.as_deref().and_then(parse_pr_url)
177 else {
178 warn!(url = ?n.subject.url, "could not parse PR url from notification");
179 continue;
180 };
181 let scope = format!("{owner}/{repo}#{number}");
182
183 let seen_key = format!("thread:{scope}");
186 let last_seen = state.get_cursor(SOURCE_ID, &seen_key).await?;
187 if let (Some(seen), Some(updated)) = (&last_seen, &n.updated_at) {
188 if updated.as_str() <= seen.as_str() {
189 continue;
190 }
191 }
192
193 let pr_data = match self.fetch_pr(&owner, &repo, number).await {
194 Ok(d) => d,
195 Err(e) => {
196 warn!(%scope, error = %e, "failed to fetch PR; skipping");
198 continue;
199 }
200 };
201
202 let old: PrSnapshot = match state.get_snapshot(SOURCE_ID, &scope).await? {
203 Some(bytes) => serde_json::from_slice(&bytes)
204 .map_err(|e| SourceError::Parse(format!("snapshot {scope}: {e}")))?,
205 None => PrSnapshot::default(),
206 };
207
208 let ctx = DiffContext {
209 source_id: SOURCE_ID.to_string(),
210 viewer_login: viewer.clone(),
211 repo: Repo {
212 owner: owner.clone(),
213 name: repo.clone(),
214 url: n.repository.html_url.clone(),
215 },
216 now: poll_start,
217 };
218 let (evs, new_snapshot) = diff(&ctx, &pr_data, &old);
219
220 let bytes = serde_json::to_vec(&new_snapshot)
221 .map_err(|e| SourceError::Parse(format!("serialize snapshot {scope}: {e}")))?;
222 state.put_snapshot(SOURCE_ID, &scope, &bytes).await?;
223 if let Some(updated) = &n.updated_at {
224 state.put_cursor(SOURCE_ID, &seen_key, updated).await?;
225 }
226
227 events.extend(evs);
228 }
229
230 let next_since = (poll_start - SINCE_OVERLAP)
233 .format(&Rfc3339)
234 .map_err(|e| SourceError::Other(Box::new(e)))?;
235 state
236 .put_cursor(SOURCE_ID, "notif_since", &next_since)
237 .await?;
238
239 Ok(events)
240 }
241}
242
243fn parse_pr_url(url: &str) -> Option<(String, String, u64)> {
245 let after = url.split("/repos/").nth(1)?;
246 let mut parts = after.split('/');
247 let owner = parts.next()?.to_string();
248 let repo = parts.next()?.to_string();
249 let kind = parts.next()?; if kind != "pulls" {
251 return None;
252 }
253 let number: u64 = parts.next()?.parse().ok()?;
254 Some((owner, repo, number))
255}
256
257fn map_err(err: octocrab::Error) -> SourceError {
259 let msg = err.to_string();
260 if msg.contains("rate limit") || msg.contains("403") {
261 return SourceError::RateLimited {
262 retry_after_secs: 60,
263 };
264 }
265 if msg.contains("401") || msg.contains("Bad credentials") {
266 return SourceError::Auth(msg);
267 }
268 SourceError::Request(msg)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::parse_pr_url;
274
275 #[test]
276 fn parses_pr_url() {
277 assert_eq!(
278 parse_pr_url("https://api.github.com/repos/acme/widgets/pulls/12"),
279 Some(("acme".into(), "widgets".into(), 12))
280 );
281 }
282
283 #[test]
284 fn rejects_non_pull_urls() {
285 assert_eq!(
286 parse_pr_url("https://api.github.com/repos/acme/widgets/issues/12"),
287 None
288 );
289 }
290}