main
1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::process::Command;
5
6#[derive(Debug, Deserialize, Serialize, Clone)]
7pub struct Notification {
8 pub id: String,
9 pub unread: bool,
10 pub reason: String,
11 pub updated_at: DateTime<Utc>,
12 pub last_read_at: Option<DateTime<Utc>>,
13 pub subject: Subject,
14 pub repository: Repository,
15}
16
17#[derive(Debug, Deserialize, Serialize, Clone)]
18pub struct Subject {
19 pub title: String,
20 #[serde(rename = "type")]
21 pub subject_type: String,
22 pub url: Option<String>,
23}
24
25#[derive(Debug, Deserialize, Serialize, Clone)]
26pub struct Repository {
27 pub full_name: String,
28 pub name: String,
29 pub owner: Owner,
30}
31
32#[derive(Debug, Deserialize, Serialize, Clone)]
33pub struct Owner {
34 pub login: String,
35}
36
37pub struct GitHubClient {
38 token: String,
39 client: reqwest::blocking::Client,
40}
41
42impl GitHubClient {
43 /// Create a new GitHub client with authentication
44 ///
45 /// Tries to get token from:
46 /// 1. `gh auth token` command (if gh CLI is available)
47 /// 2. GITHUB_TOKEN environment variable
48 pub fn new() -> Result<Self> {
49 let token = Self::get_token()?;
50
51 let client = reqwest::blocking::Client::builder()
52 .user_agent("github-notif-manager/0.1.0")
53 .build()
54 .context("Failed to create HTTP client")?;
55
56 Ok(Self { token, client })
57 }
58
59 /// Get GitHub token using gh CLI or environment variable
60 fn get_token() -> Result<String> {
61 // Try gh auth token first
62 if let Ok(output) = Command::new("gh").arg("auth").arg("token").output() {
63 if output.status.success() {
64 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
65 if !token.is_empty() {
66 return Ok(token);
67 }
68 }
69 }
70
71 // Fall back to GITHUB_TOKEN environment variable
72 std::env::var("GITHUB_TOKEN")
73 .or_else(|_| std::env::var("GH_TOKEN"))
74 .context("No GitHub token found. Either run 'gh auth login' or set GITHUB_TOKEN environment variable")
75 }
76
77 /// Fetch notifications from GitHub
78 ///
79 /// If `since` is provided, only fetches notifications updated after that timestamp.
80 pub fn get_notifications(
81 &self,
82 all: bool,
83 since: Option<&str>,
84 progress: Option<&indicatif::ProgressBar>,
85 ) -> Result<Vec<Notification>> {
86 let mut notifications = Vec::new();
87 let mut page = 1;
88 let base_url = "https://api.github.com/notifications";
89
90 loop {
91 let mut url = format!("{}?all={}&per_page=100&page={}", base_url, all, page);
92 if let Some(since) = since {
93 url.push_str(&format!("&since={}", since));
94 }
95
96 let response = self
97 .client
98 .get(&url)
99 .header("Authorization", format!("Bearer {}", self.token))
100 .header("Accept", "application/vnd.github+json")
101 .header("X-GitHub-Api-Version", "2022-11-28")
102 .send()
103 .context("Failed to fetch notifications")?;
104
105 if !response.status().is_success() {
106 anyhow::bail!(
107 "GitHub API returned error: {}",
108 response.status(),
109 );
110 }
111
112 let page_notifications: Vec<Notification> = response
113 .json()
114 .context("Failed to parse notifications JSON")?;
115
116 if page_notifications.is_empty() {
117 break;
118 }
119
120 notifications.extend(page_notifications);
121
122 if let Some(pb) = progress {
123 pb.set_message(format!("{} notifications", notifications.len()));
124 pb.tick();
125 }
126
127 page += 1;
128 }
129
130 Ok(notifications)
131 }
132
133 /// Mark a notification thread as done (archive it)
134 pub fn mark_thread_done(&self, thread_id: &str) -> Result<()> {
135 let url = format!("https://api.github.com/notifications/threads/{}", thread_id);
136
137 let response = self
138 .client
139 .delete(&url)
140 .header("Authorization", format!("Bearer {}", self.token))
141 .header("Accept", "application/vnd.github+json")
142 .header("X-GitHub-Api-Version", "2022-11-28")
143 .send()
144 .context("Failed to mark thread as done")?;
145
146 let status = response.status().as_u16();
147 if status != 204 && status != 205 {
148 anyhow::bail!("Failed to mark thread as done: {}", response.status());
149 }
150
151 Ok(())
152 }
153}