Commit 1761f389a942
Changed files (11)
tools
github-notif-manager
tools/github-notif-manager/src/actions.rs
@@ -0,0 +1,121 @@
+use crate::config::{Action, Rule};
+use crate::github::{GitHubClient, Notification};
+
+fn truncate_str(s: &str, max: usize) -> String {
+ if s.len() > max {
+ format!("{}…", &s[..max - 1])
+ } else {
+ s.to_string()
+ }
+}
+
+
+pub struct ActionResult {
+ pub notification_id: String,
+ pub action: Action,
+ pub success: bool,
+ pub error: Option<String>,
+ pub rule_name: String,
+ pub repository: String,
+ pub subject_title: String,
+ pub subject_type: String,
+}
+
+pub struct ActionExecutor<'a> {
+ client: &'a GitHubClient,
+ dry_run: bool,
+}
+
+impl<'a> ActionExecutor<'a> {
+ pub fn new(client: &'a GitHubClient, dry_run: bool) -> Self {
+ Self { client, dry_run }
+ }
+
+ /// Execute an action on a notification
+ pub fn execute(
+ &self,
+ notification: &Notification,
+ action: &Action,
+ rule_name: &str,
+ ) -> ActionResult {
+ let mut result = ActionResult {
+ notification_id: notification.id.clone(),
+ action: action.clone(),
+ success: true,
+ error: None,
+ rule_name: rule_name.to_string(),
+ repository: notification.repository.full_name.clone(),
+ subject_title: notification.subject.title.clone(),
+ subject_type: notification.subject.subject_type.clone(),
+ };
+
+ // Skip action doesn't do anything
+ if matches!(action, Action::Skip) {
+ return result;
+ }
+
+ // In dry-run mode, just report success
+ if self.dry_run {
+ return result;
+ }
+
+ // Execute the actual action
+ let exec_result = match action {
+ Action::MarkRead => self.client.mark_thread_read(¬ification.id),
+ Action::MarkDone => self.client.mark_thread_done(¬ification.id),
+ Action::Skip => Ok(()), // Already handled above
+ };
+
+ if let Err(e) = exec_result {
+ result.success = false;
+ result.error = Some(e.to_string());
+ }
+
+ result
+ }
+
+ /// Execute actions on a batch of notifications
+ pub fn execute_batch(
+ &self,
+ notifications: &[Notification],
+ rules: &[&Rule],
+ progress: Option<&indicatif::ProgressBar>,
+ ) -> Vec<ActionResult> {
+ use crate::rules::RuleMatcher;
+
+ let mut results = Vec::new();
+
+ for (i, notification) in notifications.iter().enumerate() {
+ if let Some(pb) = progress {
+ pb.set_position(i as u64);
+ }
+
+ if let Some((rule_index, rule)) = RuleMatcher::find_matching_rule(notification, rules)
+ {
+ let default_name = format!("Rule {}", rule_index + 1);
+ let rule_name = rule
+ .name
+ .as_ref()
+ .map(|s| s.as_str())
+ .unwrap_or(&default_name);
+
+ if let Some(pb) = progress {
+ pb.set_message(format!(
+ "{} {}",
+ rule.action,
+ truncate_str(¬ification.repository.full_name, 30),
+ ));
+ }
+
+ let result = self.execute(notification, &rule.action, rule_name);
+ results.push(result);
+ }
+ }
+
+ if let Some(pb) = progress {
+ pb.finish_and_clear();
+ }
+
+ results
+ }
+}
tools/github-notif-manager/src/cache.rs
@@ -0,0 +1,110 @@
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use std::collections::HashSet;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use crate::github::Notification;
+
+/// Persistent state stored between runs
+#[derive(Debug, Serialize, Deserialize, Default)]
+pub struct Cache {
+ /// Timestamp of the last successful fetch
+ pub last_fetched: Option<DateTime<Utc>>,
+ /// Cached notifications
+ pub notifications: Vec<Notification>,
+ /// IDs of notifications we've already marked as done.
+ /// GitHub's API still returns "done" notifications with all=true,
+ /// so we track them to filter them out on re-fetch.
+ #[serde(default)]
+ pub done_ids: HashSet<String>,
+}
+
+impl Cache {
+ /// Default cache file location
+ pub fn default_path() -> PathBuf {
+ let cache_dir = dirs::cache_dir()
+ .unwrap_or_else(|| PathBuf::from("~/.cache"))
+ .join("github-notif-manager");
+ cache_dir.join("cache.json")
+ }
+
+ /// Load cache from disk, or return empty cache if not found
+ pub fn load(path: &Path) -> Result<Self> {
+ if !path.exists() {
+ return Ok(Self::default());
+ }
+
+ let content = fs::read_to_string(path)
+ .context(format!("Failed to read cache file: {:?}", path))?;
+
+ serde_json::from_str(&content)
+ .context("Failed to parse cache file (delete it to reset)")
+ }
+
+ /// Save cache to disk
+ pub fn save(&self, path: &Path) -> Result<()> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .context(format!("Failed to create cache directory: {:?}", parent))?;
+ }
+
+ let content = serde_json::to_string_pretty(self)
+ .context("Failed to serialize cache")?;
+
+ fs::write(path, content)
+ .context(format!("Failed to write cache file: {:?}", path))?;
+
+ Ok(())
+ }
+
+ /// Merge new notifications into cache.
+ /// Filters out notifications we've already marked as done.
+ pub fn merge(&mut self, fresh: Vec<Notification>, full_fetch: bool) {
+ // Filter out notifications we've already marked as done
+ let fresh: Vec<Notification> = fresh
+ .into_iter()
+ .filter(|n| !self.done_ids.contains(&n.id))
+ .collect();
+
+ if full_fetch {
+ self.notifications = fresh;
+ } else {
+ for new_notif in fresh {
+ if let Some(existing) = self
+ .notifications
+ .iter_mut()
+ .find(|n| n.id == new_notif.id)
+ {
+ *existing = new_notif;
+ } else {
+ self.notifications.push(new_notif);
+ }
+ }
+ }
+
+ self.last_fetched = Some(Utc::now());
+ }
+
+ /// Mark a notification as done — removes from notifications and tracks ID
+ pub fn mark_done(&mut self, notification_id: &str) {
+ self.notifications.retain(|n| n.id != notification_id);
+ self.done_ids.insert(notification_id.to_string());
+ }
+
+ /// Remove a notification from cache (for mark_read — keep in cache but update)
+ pub fn remove(&mut self, notification_id: &str) {
+ self.notifications.retain(|n| n.id != notification_id);
+ }
+
+ /// Get the `since` parameter for incremental fetching
+ pub fn since_param(&self) -> Option<String> {
+ self.last_fetched.map(|dt| dt.to_rfc3339())
+ }
+
+ /// Number of tracked done IDs
+ pub fn done_count(&self) -> usize {
+ self.done_ids.len()
+ }
+}
tools/github-notif-manager/src/config.rs
@@ -0,0 +1,128 @@
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::fs;
+use std::path::Path;
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct Config {
+ pub rules: Vec<Rule>,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct Rule {
+ pub name: Option<String>,
+ pub description: Option<String>,
+ pub filters: HashMap<String, FilterValue>,
+ pub action: Action,
+ #[serde(default = "default_enabled")]
+ pub enabled: bool,
+}
+
+fn default_enabled() -> bool {
+ true
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
+#[serde(rename_all = "snake_case")]
+pub enum Action {
+ MarkRead,
+ MarkDone,
+ Skip,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+#[serde(untagged)]
+pub enum FilterValue {
+ String(String),
+ Bool(bool),
+ Number(i64),
+}
+
+impl Config {
+ /// Load configuration from a YAML file
+ pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
+ let content = fs::read_to_string(path.as_ref())
+ .context(format!("Failed to read config file: {:?}", path.as_ref()))?;
+
+ let config: Config = serde_yaml::from_str(&content)
+ .context("Failed to parse YAML configuration")?;
+
+ config.validate()?;
+ Ok(config)
+ }
+
+ /// Validate the configuration
+ pub fn validate(&self) -> Result<()> {
+ const VALID_FILTERS: &[&str] = &[
+ "repository",
+ "reason",
+ "subject_type",
+ "unread",
+ "age_days",
+ "updated_before",
+ "updated_after",
+ ];
+
+ for (i, rule) in self.rules.iter().enumerate() {
+ let rule_id = rule
+ .name
+ .as_ref()
+ .map(|n| format!("Rule '{}'", n))
+ .unwrap_or_else(|| format!("Rule {}", i + 1));
+
+ // Check that filters is not empty
+ if rule.filters.is_empty() {
+ anyhow::bail!("{}: filters cannot be empty", rule_id);
+ }
+
+ // Validate filter field names
+ for field in rule.filters.keys() {
+ if !VALID_FILTERS.contains(&field.as_str()) {
+ anyhow::bail!(
+ "{}: invalid filter field '{}'. Valid fields: {}",
+ rule_id,
+ field,
+ VALID_FILTERS.join(", ")
+ );
+ }
+ }
+
+ // Validate filter types
+ if let Some(FilterValue::String(_)) = rule.filters.get("unread") {
+ anyhow::bail!("{}: 'unread' filter must be a boolean", rule_id);
+ }
+
+ if let Some(FilterValue::String(_)) = rule.filters.get("age_days") {
+ anyhow::bail!("{}: 'age_days' filter must be a number", rule_id);
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Get all enabled rules
+ pub fn enabled_rules(&self) -> Vec<&Rule> {
+ self.rules.iter().filter(|r| r.enabled).collect()
+ }
+
+ /// Get count of enabled rules
+ pub fn enabled_count(&self) -> usize {
+ self.rules.iter().filter(|r| r.enabled).count()
+ }
+
+ /// Get count of disabled rules
+ pub fn disabled_count(&self) -> usize {
+ self.rules.iter().filter(|r| !r.enabled).count()
+ }
+}
+
+impl std::fmt::Display for Action {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Action::MarkRead => write!(f, "mark_read"),
+ Action::MarkDone => write!(f, "mark_done"),
+ Action::Skip => write!(f, "skip"),
+ }
+ }
+}
tools/github-notif-manager/src/github.rs
@@ -0,0 +1,174 @@
+use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use std::process::Command;
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct Notification {
+ pub id: String,
+ pub unread: bool,
+ pub reason: String,
+ pub updated_at: DateTime<Utc>,
+ pub last_read_at: Option<DateTime<Utc>>,
+ pub subject: Subject,
+ pub repository: Repository,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct Subject {
+ pub title: String,
+ #[serde(rename = "type")]
+ pub subject_type: String,
+ pub url: Option<String>,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct Repository {
+ pub full_name: String,
+ pub name: String,
+ pub owner: Owner,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct Owner {
+ pub login: String,
+}
+
+pub struct GitHubClient {
+ token: String,
+ client: reqwest::blocking::Client,
+}
+
+impl GitHubClient {
+ /// Create a new GitHub client with authentication
+ ///
+ /// Tries to get token from:
+ /// 1. `gh auth token` command (if gh CLI is available)
+ /// 2. GITHUB_TOKEN environment variable
+ pub fn new() -> Result<Self> {
+ let token = Self::get_token()?;
+
+ let client = reqwest::blocking::Client::builder()
+ .user_agent("github-notif-manager/0.1.0")
+ .build()
+ .context("Failed to create HTTP client")?;
+
+ Ok(Self { token, client })
+ }
+
+ /// Get GitHub token using gh CLI or environment variable
+ fn get_token() -> Result<String> {
+ // Try gh auth token first
+ if let Ok(output) = Command::new("gh").arg("auth").arg("token").output() {
+ if output.status.success() {
+ let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if !token.is_empty() {
+ return Ok(token);
+ }
+ }
+ }
+
+ // Fall back to GITHUB_TOKEN environment variable
+ std::env::var("GITHUB_TOKEN")
+ .or_else(|_| std::env::var("GH_TOKEN"))
+ .context("No GitHub token found. Either run 'gh auth login' or set GITHUB_TOKEN environment variable")
+ }
+
+ /// Fetch notifications from GitHub
+ ///
+ /// If `since` is provided, only fetches notifications updated after that timestamp.
+ pub fn get_notifications(
+ &self,
+ all: bool,
+ since: Option<&str>,
+ progress: Option<&indicatif::ProgressBar>,
+ ) -> Result<Vec<Notification>> {
+ let mut notifications = Vec::new();
+ let mut page = 1;
+ let base_url = "https://api.github.com/notifications";
+
+ loop {
+ let mut url = format!("{}?all={}&per_page=100&page={}", base_url, all, page);
+ if let Some(since) = since {
+ url.push_str(&format!("&since={}", since));
+ }
+
+ let response = self
+ .client
+ .get(&url)
+ .header("Authorization", format!("Bearer {}", self.token))
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", "2022-11-28")
+ .send()
+ .context("Failed to fetch notifications")?;
+
+ if !response.status().is_success() {
+ anyhow::bail!(
+ "GitHub API returned error: {} - {}",
+ response.status(),
+ response.text().unwrap_or_default()
+ );
+ }
+
+ let page_notifications: Vec<Notification> = response
+ .json()
+ .context("Failed to parse notifications JSON")?;
+
+ if page_notifications.is_empty() {
+ break;
+ }
+
+ notifications.extend(page_notifications);
+
+ if let Some(pb) = progress {
+ pb.set_message(format!("{} notifications", notifications.len()));
+ pb.tick();
+ }
+
+ page += 1;
+ }
+
+ Ok(notifications)
+ }
+
+ /// Mark a notification thread as read
+ pub fn mark_thread_read(&self, thread_id: &str) -> Result<()> {
+ let url = format!("https://api.github.com/notifications/threads/{}", thread_id);
+
+ let response = self
+ .client
+ .patch(&url)
+ .header("Authorization", format!("Bearer {}", self.token))
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", "2022-11-28")
+ .send()
+ .context("Failed to mark thread as read")?;
+
+ if response.status().as_u16() != 205 {
+ anyhow::bail!("Failed to mark thread as read: {}", response.status());
+ }
+
+ Ok(())
+ }
+
+ /// Mark a notification thread as done (archive it)
+ pub fn mark_thread_done(&self, thread_id: &str) -> Result<()> {
+ let url = format!("https://api.github.com/notifications/threads/{}", thread_id);
+
+ let response = self
+ .client
+ .delete(&url)
+ .header("Authorization", format!("Bearer {}", self.token))
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", "2022-11-28")
+ .send()
+ .context("Failed to mark thread as done")?;
+
+ let status = response.status().as_u16();
+ if status != 204 && status != 205 {
+ anyhow::bail!("Failed to mark thread as done: {}", response.status());
+ }
+
+ Ok(())
+ }
+}
tools/github-notif-manager/src/main.rs
@@ -0,0 +1,567 @@
+mod actions;
+mod cache;
+mod config;
+mod github;
+mod rules;
+
+use anyhow::{Context, Result};
+use clap::{Parser, Subcommand};
+use colored::*;
+use comfy_table::modifiers::UTF8_ROUND_CORNERS;
+use comfy_table::presets::UTF8_FULL;
+use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table};
+use indicatif::{ProgressBar, ProgressStyle};
+use std::io::IsTerminal;
+use std::path::PathBuf;
+
+use crate::actions::ActionExecutor;
+use crate::cache::Cache;
+use crate::config::{Action, Config};
+use crate::github::GitHubClient;
+
+#[derive(Parser)]
+#[command(name = "github-notif-manager")]
+#[command(about = "Rule-based GitHub notification automation", version, long_about = None)]
+struct Cli {
+ #[command(subcommand)]
+ command: Commands,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ /// Process notifications according to rules
+ Process {
+ /// Path to configuration file
+ #[arg(short, long)]
+ config: PathBuf,
+
+ /// Show what would be done without making changes
+ #[arg(short = 'n', long)]
+ dry_run: bool,
+
+ /// Show detailed output including skipped notifications
+ #[arg(short, long)]
+ verbose: bool,
+
+ /// Fetch all notifications (ignore cache, do a full refresh)
+ #[arg(short, long)]
+ all: bool,
+
+ /// Custom cache file path (default: ~/.local/share/github-notif-manager/cache.json)
+ #[arg(long)]
+ cache_dir: Option<PathBuf>,
+ },
+
+ /// Validate configuration file
+ Validate {
+ /// Path to configuration file
+ #[arg(short, long)]
+ config: PathBuf,
+ },
+
+ /// List recent notifications (for testing/debugging)
+ List {
+ /// Number of notifications to show
+ #[arg(short, long, default_value = "10")]
+ limit: usize,
+
+ /// Fetch all notifications (ignore cache, do a full refresh)
+ #[arg(short, long)]
+ all: bool,
+
+ /// Custom cache file path
+ #[arg(long)]
+ cache_dir: Option<PathBuf>,
+ },
+
+ /// Show cache status
+ Cache {
+ /// Custom cache file path
+ #[arg(long)]
+ cache_dir: Option<PathBuf>,
+
+ /// Clear the cache
+ #[arg(long)]
+ clear: bool,
+ },
+}
+
+fn main() -> Result<()> {
+ let cli = Cli::parse();
+
+ match cli.command {
+ Commands::Process {
+ config,
+ dry_run,
+ verbose,
+ all,
+ cache_dir,
+ } => cmd_process(config, dry_run, verbose, all, cache_dir),
+ Commands::Validate { config } => cmd_validate(config),
+ Commands::List {
+ limit,
+ all,
+ cache_dir,
+ } => cmd_list(limit, all, cache_dir),
+ Commands::Cache { cache_dir, clear } => cmd_cache(cache_dir, clear),
+ }
+}
+
+fn cache_path(cache_dir: Option<PathBuf>) -> PathBuf {
+ cache_dir.unwrap_or_else(Cache::default_path)
+}
+
+fn is_tty() -> bool {
+ std::io::stderr().is_terminal()
+}
+
+fn truncate(s: &str, max: usize) -> String {
+ if s.len() > max {
+ format!("{}…", &s[..max - 1])
+ } else {
+ s.to_string()
+ }
+}
+
+/// Fetch notifications, using cache for incremental updates
+fn fetch_notifications(
+ client: &GitHubClient,
+ cache: &mut Cache,
+ force_all: bool,
+) -> Result<()> {
+ let (since, full_fetch) = if force_all || cache.last_fetched.is_none() {
+ (None, true)
+ } else {
+ (cache.since_param(), false)
+ };
+
+ let tty = is_tty();
+ let pb = if tty {
+ let pb = ProgressBar::new_spinner();
+ pb.set_style(
+ ProgressStyle::with_template("{spinner:.cyan} {msg}")
+ .unwrap()
+ .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
+ );
+ if full_fetch {
+ pb.set_message("Fetching all notifications…");
+ } else {
+ pb.set_message(format!(
+ "Fetching notifications since {} ({} cached)…",
+ cache
+ .last_fetched
+ .map(|d| d.format("%Y-%m-%d %H:%M UTC").to_string())
+ .unwrap_or_default(),
+ cache.notifications.len(),
+ ));
+ }
+ Some(pb)
+ } else {
+ None
+ };
+
+ let fresh = client
+ .get_notifications(true, since.as_deref(), pb.as_ref())
+ .context("Failed to fetch notifications")?;
+
+ let fresh_count = fresh.len();
+ cache.merge(fresh, full_fetch);
+
+ if let Some(pb) = pb {
+ pb.finish_and_clear();
+ }
+
+ if full_fetch {
+ eprintln!(
+ " {} Fetched {} notifications (filtered {} done)",
+ if tty { "✓".green().to_string() } else { "OK".to_string() },
+ cache.notifications.len(),
+ cache.done_count()
+ );
+ } else {
+ eprintln!(
+ " {} Fetched {} new/updated, {} total cached",
+ if tty { "✓".green().to_string() } else { "OK".to_string() },
+ fresh_count,
+ cache.notifications.len()
+ );
+ }
+
+ Ok(())
+}
+
+fn cmd_process(
+ config_path: PathBuf,
+ dry_run: bool,
+ verbose: bool,
+ force_all: bool,
+ cache_dir: Option<PathBuf>,
+) -> Result<()> {
+ let cp = cache_path(cache_dir);
+ let tty = is_tty();
+
+ // Load configuration
+ let config = Config::from_file(&config_path)?;
+ let enabled_rules = config.enabled_rules();
+
+ // Print header
+ if tty {
+ println!("\n{}", "═".repeat(60).bright_blue());
+ println!("{}", " GitHub Notification Manager".bright_blue().bold());
+ println!("{}", "═".repeat(60).bright_blue());
+ println!("Config: {}", config_path.display());
+ println!("Cache: {}", cp.display());
+ println!(
+ "Rules: {} enabled, {} disabled",
+ config.enabled_count(),
+ config.disabled_count()
+ );
+ println!(
+ "Mode: {}",
+ if dry_run { "DRY RUN".yellow() } else { "LIVE".green() }
+ );
+ println!("{}", "═".repeat(60).bright_blue());
+ println!();
+ } else {
+ eprintln!(
+ "github-notif-manager: config={} rules={} mode={}",
+ config_path.display(),
+ config.enabled_count(),
+ if dry_run { "dry-run" } else { "live" }
+ );
+ }
+
+ // Initialize GitHub client
+ let client = GitHubClient::new()?;
+
+ // Load cache and fetch notifications
+ let mut cache = Cache::load(&cp)?;
+ fetch_notifications(&client, &mut cache, force_all)?;
+
+ if cache.notifications.is_empty() {
+ eprintln!(" No notifications to process");
+ cache.save(&cp)?;
+ return Ok(());
+ }
+
+ // Process notifications
+ let total_notifications = cache.notifications.len();
+
+ let pb = if tty {
+ let pb = ProgressBar::new(total_notifications as u64);
+ pb.set_style(
+ ProgressStyle::with_template(
+ " {spinner:.cyan} Processing [{bar:30.cyan/dim}] {pos}/{len} {msg}",
+ )
+ .unwrap()
+ .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
+ .progress_chars("━╸─"),
+ );
+ Some(pb)
+ } else {
+ None
+ };
+
+ let executor = ActionExecutor::new(&client, dry_run);
+ let results = executor.execute_batch(&cache.notifications, &enabled_rules, pb.as_ref());
+
+ // Track done notifications in cache (unless dry-run)
+ if !dry_run {
+ let done_ids: Vec<String> = results
+ .iter()
+ .filter(|r| matches!(r.action, Action::MarkDone) && r.success)
+ .map(|r| r.notification_id.clone())
+ .collect();
+ for id in &done_ids {
+ cache.mark_done(id);
+ }
+ }
+
+ // Save cache
+ cache.save(&cp)?;
+
+ // Calculate statistics
+ let mut stats = std::collections::HashMap::new();
+ for action in &[Action::MarkRead, Action::MarkDone, Action::Skip] {
+ stats.insert(
+ action.to_string(),
+ (
+ results
+ .iter()
+ .filter(|r| &r.action == action && r.success)
+ .count(),
+ results
+ .iter()
+ .filter(|r| &r.action == action && !r.success)
+ .count(),
+ ),
+ );
+ }
+ let no_match = total_notifications - results.len();
+
+ let (done_ok, done_fail) = stats.get("mark_done").unwrap_or(&(0, 0));
+ let (read_ok, read_fail) = stats.get("mark_read").unwrap_or(&(0, 0));
+ let (skip_ok, _) = stats.get("skip").unwrap_or(&(0, 0));
+
+ if tty {
+ // Display results table
+ let visible_results: Vec<_> = results
+ .iter()
+ .filter(|r| verbose || !matches!(r.action, Action::Skip))
+ .collect();
+
+ if !visible_results.is_empty() {
+ let mut table = Table::new();
+ table
+ .load_preset(UTF8_FULL)
+ .apply_modifier(UTF8_ROUND_CORNERS)
+ .set_content_arrangement(ContentArrangement::Dynamic)
+ .set_header(vec![
+ Cell::new("Action"),
+ Cell::new("Repository"),
+ Cell::new("Type"),
+ Cell::new("Subject"),
+ Cell::new("Rule"),
+ ]);
+
+ for result in &visible_results {
+ let (action_str, action_color) = match result.action {
+ Action::MarkDone => ("done", Color::Yellow),
+ Action::MarkRead => ("read", Color::Blue),
+ Action::Skip => ("skip", Color::DarkGrey),
+ };
+
+ let status = if result.success { "" } else { " ✗" };
+
+ table.add_row(vec![
+ Cell::new(format!("{}{}", action_str, status)).fg(action_color),
+ Cell::new(truncate(&result.repository, 30)).fg(Color::Cyan),
+ Cell::new(&result.subject_type).fg(Color::Magenta),
+ Cell::new(truncate(&result.subject_title, 40)),
+ Cell::new(truncate(&result.rule_name, 28)).fg(Color::Green),
+ ]);
+ }
+
+ println!();
+ println!("{table}");
+ }
+
+ // Display summary table
+ println!("\n{}", "Summary".bright_cyan().bold());
+
+ let mut summary = Table::new();
+ summary
+ .load_preset(UTF8_FULL)
+ .apply_modifier(UTF8_ROUND_CORNERS)
+ .set_content_arrangement(ContentArrangement::Dynamic)
+ .set_header(vec![
+ Cell::new("Action"),
+ Cell::new("Count").set_alignment(CellAlignment::Right),
+ Cell::new("Failed").set_alignment(CellAlignment::Right),
+ ]);
+
+ summary.add_row(vec![
+ Cell::new("Mark as Done"),
+ Cell::new(done_ok).set_alignment(CellAlignment::Right),
+ Cell::new(done_fail).set_alignment(CellAlignment::Right),
+ ]);
+ summary.add_row(vec![
+ Cell::new("Mark as Read"),
+ Cell::new(read_ok).set_alignment(CellAlignment::Right),
+ Cell::new(read_fail).set_alignment(CellAlignment::Right),
+ ]);
+ summary.add_row(vec![
+ Cell::new("Skipped (kept)"),
+ Cell::new(skip_ok).set_alignment(CellAlignment::Right),
+ Cell::new("-").set_alignment(CellAlignment::Right),
+ ]);
+ summary.add_row(vec![
+ Cell::new("No match"),
+ Cell::new(no_match).set_alignment(CellAlignment::Right),
+ Cell::new("-").set_alignment(CellAlignment::Right),
+ ]);
+
+ println!("{summary}");
+
+ if dry_run {
+ println!("\n{}", "DRY RUN: No changes were made".yellow());
+ }
+ } else {
+ // Non-TTY: simple log-style output
+ println!(
+ "done={} read={} skipped={} no_match={} done_failed={} read_failed={}",
+ done_ok, read_ok, skip_ok, no_match, done_fail, read_fail
+ );
+ if dry_run {
+ println!("mode=dry-run");
+ }
+ }
+
+ Ok(())
+}
+
+fn cmd_validate(config_path: PathBuf) -> Result<()> {
+ let config = Config::from_file(&config_path)?;
+
+ println!("\n{}", "✓ Configuration is valid".green().bold());
+ println!();
+ println!("Total rules: {}", config.rules.len());
+ println!("Enabled rules: {}", config.enabled_count());
+ println!("Disabled rules: {}", config.disabled_count());
+
+ let mut table = Table::new();
+ table
+ .load_preset(UTF8_FULL)
+ .apply_modifier(UTF8_ROUND_CORNERS)
+ .set_content_arrangement(ContentArrangement::Dynamic)
+ .set_header(vec![
+ Cell::new("#").set_alignment(CellAlignment::Right),
+ Cell::new("On"),
+ Cell::new("Name"),
+ Cell::new("Action"),
+ Cell::new("Filters"),
+ ]);
+
+ for (i, rule) in config.rules.iter().enumerate() {
+ let (enabled, enabled_color) = if rule.enabled {
+ ("✓", Color::Green)
+ } else {
+ ("✗", Color::Red)
+ };
+ let default_name = format!("Rule {}", i + 1);
+ let name = rule
+ .name
+ .as_ref()
+ .map(|s| s.as_str())
+ .unwrap_or(&default_name);
+ let filters: Vec<String> = rule
+ .filters
+ .iter()
+ .map(|(k, v)| match v {
+ crate::config::FilterValue::String(s) => format!("{}={}", k, s),
+ crate::config::FilterValue::Bool(b) => format!("{}={}", k, b),
+ crate::config::FilterValue::Number(n) => format!("{}={}", k, n),
+ })
+ .collect();
+
+ table.add_row(vec![
+ Cell::new(i + 1).set_alignment(CellAlignment::Right),
+ Cell::new(enabled).fg(enabled_color),
+ Cell::new(name).fg(Color::Cyan),
+ Cell::new(rule.action.to_string()).fg(Color::Yellow),
+ Cell::new(filters.join(", ")),
+ ]);
+ }
+
+ println!();
+ println!("{table}");
+
+ Ok(())
+}
+
+fn cmd_list(limit: usize, force_all: bool, cache_dir: Option<PathBuf>) -> Result<()> {
+ let cp = cache_path(cache_dir);
+ let client = GitHubClient::new()?;
+
+ // Load cache and fetch
+ let mut cache = Cache::load(&cp)?;
+ fetch_notifications(&client, &mut cache, force_all)?;
+ cache.save(&cp)?;
+
+ let notifications = &cache.notifications;
+
+ if notifications.is_empty() {
+ println!("{}", "No notifications found".yellow());
+ return Ok(());
+ }
+
+ println!(
+ "\nShowing first {} of {}:\n",
+ std::cmp::min(limit, notifications.len()),
+ notifications.len().to_string().bold()
+ );
+
+ let mut table = Table::new();
+ table
+ .load_preset(UTF8_FULL)
+ .apply_modifier(UTF8_ROUND_CORNERS)
+ .set_content_arrangement(ContentArrangement::Dynamic)
+ .set_header(vec![
+ Cell::new(""),
+ Cell::new("Repository"),
+ Cell::new("Type"),
+ Cell::new("Reason"),
+ Cell::new("Subject"),
+ ]);
+
+ for notif in notifications.iter().take(limit) {
+ let (unread, unread_color) = if notif.unread {
+ ("●", Color::Green)
+ } else {
+ ("○", Color::DarkGrey)
+ };
+
+ table.add_row(vec![
+ Cell::new(unread).fg(unread_color),
+ Cell::new(truncate(¬if.repository.full_name, 35)).fg(Color::Cyan),
+ Cell::new(¬if.subject.subject_type).fg(Color::Magenta),
+ Cell::new(¬if.reason).fg(Color::Yellow),
+ Cell::new(truncate(¬if.subject.title, 45)),
+ ]);
+ }
+
+ println!("{table}");
+
+ Ok(())
+}
+
+fn cmd_cache(cache_dir: Option<PathBuf>, clear: bool) -> Result<()> {
+ let cp = cache_path(cache_dir);
+
+ if clear {
+ if cp.exists() {
+ std::fs::remove_file(&cp)?;
+ println!("{} Cache cleared: {}", "✓".green(), cp.display());
+ } else {
+ println!("{}", "No cache file found".yellow());
+ }
+ return Ok(());
+ }
+
+ if !cp.exists() {
+ println!("{}", "No cache file found".yellow());
+ println!("Path: {}", cp.display());
+ println!("Run 'list' or 'process' to populate the cache.");
+ return Ok(());
+ }
+
+ let cache = Cache::load(&cp)?;
+ let file_size = std::fs::metadata(&cp)?.len();
+
+ println!("\n{}", "Cache Status".bright_cyan().bold());
+ println!("Path: {}", cp.display());
+ println!("Size: {:.1} KB", file_size as f64 / 1024.0);
+ println!(
+ "Last fetched: {}",
+ cache
+ .last_fetched
+ .map(|d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string())
+ .unwrap_or_else(|| "never".to_string())
+ .cyan()
+ );
+ println!(
+ "Notifications: {}",
+ cache.notifications.len().to_string().bold()
+ );
+
+ if !cache.notifications.is_empty() {
+ let unread = cache.notifications.iter().filter(|n| n.unread).count();
+ let read = cache.notifications.len() - unread;
+ println!(" Unread: {}", unread.to_string().green());
+ println!(" Read: {}", read.to_string().bright_black());
+ }
+
+ println!("Done tracked: {}", cache.done_count());
+
+ Ok(())
+}
tools/github-notif-manager/src/rules.rs
@@ -0,0 +1,104 @@
+use crate::config::{FilterValue, Rule};
+use crate::github::Notification;
+use chrono::Utc;
+use wildmatch::WildMatch;
+
+pub struct RuleMatcher;
+
+impl RuleMatcher {
+ /// Check if a notification matches a rule's filters
+ pub fn matches(notification: &Notification, rule: &Rule) -> bool {
+ for (field, value) in &rule.filters {
+ let matches = match field.as_str() {
+ "repository" => Self::match_repository(notification, value),
+ "reason" => Self::match_string(¬ification.reason, value),
+ "subject_type" => Self::match_string(¬ification.subject.subject_type, value),
+ "unread" => Self::match_bool(notification.unread, value),
+ "age_days" => Self::match_age_days(notification, value),
+ "updated_before" => Self::match_updated_before(notification, value),
+ "updated_after" => Self::match_updated_after(notification, value),
+ _ => continue, // Unknown filter, skip
+ };
+
+ if !matches {
+ return false;
+ }
+ }
+
+ true
+ }
+
+ /// Find the first matching rule for a notification
+ pub fn find_matching_rule<'a>(
+ notification: &Notification,
+ rules: &'a [&Rule],
+ ) -> Option<(usize, &'a Rule)> {
+ for (i, rule) in rules.iter().enumerate() {
+ if Self::matches(notification, rule) {
+ return Some((i, rule));
+ }
+ }
+ None
+ }
+
+ fn match_repository(notification: &Notification, value: &FilterValue) -> bool {
+ if let FilterValue::String(pattern) = value {
+ let matcher = WildMatch::new(pattern);
+ matcher.matches(¬ification.repository.full_name)
+ } else {
+ false
+ }
+ }
+
+ fn match_string(field_value: &str, filter_value: &FilterValue) -> bool {
+ if let FilterValue::String(expected) = filter_value {
+ field_value == expected
+ } else {
+ false
+ }
+ }
+
+ fn match_bool(field_value: bool, filter_value: &FilterValue) -> bool {
+ if let FilterValue::Bool(expected) = filter_value {
+ field_value == *expected
+ } else {
+ false
+ }
+ }
+
+ fn match_age_days(notification: &Notification, value: &FilterValue) -> bool {
+ if let FilterValue::Number(min_days) = value {
+ let now = Utc::now();
+ let age = now.signed_duration_since(notification.updated_at);
+ age.num_days() >= *min_days
+ } else {
+ false
+ }
+ }
+
+ fn match_updated_before(notification: &Notification, value: &FilterValue) -> bool {
+ if let FilterValue::String(date_str) = value {
+ if let Ok(cutoff) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
+ let cutoff = cutoff.and_hms_opt(0, 0, 0).unwrap().and_utc();
+ notification.updated_at < cutoff
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ }
+
+ fn match_updated_after(notification: &Notification, value: &FilterValue) -> bool {
+ if let FilterValue::String(date_str) = value {
+ if let Ok(cutoff) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
+ let cutoff = cutoff.and_hms_opt(0, 0, 0).unwrap().and_utc();
+ notification.updated_at > cutoff
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ }
+}
tools/github-notif-manager/.gitignore
@@ -0,0 +1,2 @@
+target/
+Cargo.lock
tools/github-notif-manager/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "github-notif-manager"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+anyhow = "1.0"
+clap = { version = "4.5", features = ["derive"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+serde_yaml = "0.9"
+reqwest = { version = "0.12", features = ["json", "blocking", "rustls-tls"], default-features = false }
+chrono = { version = "0.4", features = ["serde"] }
+colored = "2.1"
+comfy-table = "7.1"
+wildmatch = "2.3"
+dirs = "6.0"
+indicatif = "0.17"
tools/github-notif-manager/config.example.yaml
@@ -0,0 +1,61 @@
+# GitHub Notification Manager Configuration
+#
+# This file defines rules for automatically managing GitHub notifications.
+# Rules are evaluated in order, and the first matching rule is applied.
+
+rules:
+ # Example: Archive all Dependabot notifications from a specific repo
+ - name: "Archive Dependabot notifications"
+ description: "Auto-archive dependency update notifications"
+ filters:
+ repository: "owner/repo" # Exact match or pattern with *
+ reason: "subscribed" # Notification reason (see GitHub API docs)
+ action: mark_done # mark_read, mark_done, or skip
+ enabled: true
+
+ # Example: Mark CI/CD notifications as read
+ - name: "Auto-read CI notifications"
+ description: "Mark check suite notifications as read"
+ filters:
+ subject_type: "CheckSuite" # Release, Issue, PullRequest, CheckSuite, etc.
+ action: mark_read
+ enabled: true
+
+ # Example: Archive notifications from specific repos
+ - name: "Archive archived repo notifications"
+ description: "Auto-archive notifications from archived repositories"
+ filters:
+ repository: "owner/archived-*" # Supports wildcard patterns
+ action: mark_done
+ enabled: true
+
+ # Example: Archive old read notifications
+ - name: "Archive old read notifications"
+ description: "Archive notifications that have been read for 7+ days"
+ filters:
+ unread: false
+ age_days: 7 # Older than N days
+ action: mark_done
+ enabled: true
+
+ # Example: Skip notifications from important repos (no action)
+ - name: "Keep important repo notifications"
+ description: "Don't auto-process notifications from critical repos"
+ filters:
+ repository: "owner/important-repo"
+ action: skip
+ enabled: true
+
+# Available filter fields:
+# - repository: string or pattern (supports wildcards like "owner/*")
+# - reason: string (subscribed, mention, team_mention, author, etc.)
+# - subject_type: string (Release, Issue, PullRequest, CheckSuite, Discussion, etc.)
+# - unread: boolean (true/false)
+# - age_days: number (notifications older than N days)
+# - updated_before: ISO date string (YYYY-MM-DD)
+# - updated_after: ISO date string (YYYY-MM-DD)
+
+# Available actions:
+# - mark_read: Mark notification as read (but keep in inbox)
+# - mark_done: Mark notification as done (archive it)
+# - skip: Don't process this notification (useful for exclusions)
tools/github-notif-manager/config.yaml
@@ -0,0 +1,102 @@
+# GitHub Notification Manager - vdemeester config
+#
+# Rules are evaluated in order - first match wins.
+# Order matters! Specific rules before general ones.
+
+rules:
+ # ─── KEEP rules (skip) — guardrails ───────────────────────────────────
+
+ # Direct @-mentions — someone explicitly pinged you
+ - name: "Keep mentions"
+ filters:
+ reason: "mention"
+ action: skip
+ enabled: true
+
+ # Team mentions
+ - name: "Keep team mentions"
+ filters:
+ reason: "team_mention"
+ action: skip
+ enabled: true
+
+ # Things you authored (your own PRs/issues)
+ - name: "Keep authored"
+ filters:
+ reason: "author"
+ action: skip
+ enabled: true
+
+ # Comments on things you're involved in
+ - name: "Keep comments"
+ filters:
+ reason: "comment"
+ action: skip
+ enabled: true
+
+ # Security alerts
+ - name: "Keep security alerts"
+ filters:
+ subject_type: "RepositoryDependabotAlertsThread"
+ action: skip
+ enabled: true
+
+ # NixOS/nixpkgs
+ - name: "Keep nixpkgs"
+ filters:
+ repository: "NixOS/nixpkgs"
+ action: skip
+ enabled: true
+
+ # Assignments — you were explicitly assigned
+ - name: "Keep assignments"
+ filters:
+ reason: "assign"
+ action: skip
+ enabled: true
+
+ # ─── TARGETED ARCHIVE rules — before the general keep ─────────────────
+
+ # openshift-pipelines/operator review requests (437 notifications!)
+ # These are almost all automated bot/renovate PRs
+ - name: "Archive operator bot review requests"
+ description: "Automated dependency PRs from operator repo"
+ filters:
+ repository: "openshift-pipelines/operator"
+ reason: "review_requested"
+ action: mark_done
+ enabled: true
+
+ # ─── KEEP remaining review requests ───────────────────────────────────
+
+ # Real review requests (everything NOT caught above)
+ - name: "Keep real review requests"
+ filters:
+ reason: "review_requested"
+ action: skip
+ enabled: true
+
+ # ─── ARCHIVE rules ────────────────────────────────────────────────────
+
+ # CI activity — pure noise (120 notifications)
+ - name: "Archive CI activity"
+ filters:
+ reason: "ci_activity"
+ action: mark_done
+ enabled: true
+
+ # State changes (PR merged/closed) — stale fast
+ - name: "Archive state changes"
+ filters:
+ reason: "state_change"
+ action: mark_done
+ enabled: false
+
+ # Old read notifications — general cleanup
+ - name: "Archive old read notifications"
+ description: "Notifications read 7+ days ago, clean up"
+ filters:
+ unread: false
+ age_days: 7
+ action: mark_done
+ enabled: true
tools/github-notif-manager/README.md
@@ -0,0 +1,293 @@
+# GitHub Notification Manager
+
+Rule-based automation for GitHub notifications. Define rules to automatically mark notifications as read or archive them based on repository, notification type, age, and more.
+
+Built in Rust for performance and ease of deployment (single binary).
+
+## Features
+
+- **Rule-based processing**: Define flexible rules with multiple filter criteria
+- **Multiple actions**: Mark as read, mark as done (archive), or skip
+- **Pattern matching**: Support for wildcards in repository names
+- **Age-based filtering**: Process notifications based on age or date ranges
+- **Dry-run mode**: Test rules before applying them
+- **Beautiful CLI output**: Colored terminal output with tables
+- **Validation**: Validate configuration before running
+- **Smart authentication**: Uses `gh auth token` with fallback to `GITHUB_TOKEN`
+
+## Installation
+
+### Build from source
+
+```bash
+cd ~/src/home/tools/github-notif-manager
+cargo build --release
+```
+
+The binary will be at `target/release/github-notif-manager`.
+
+### Authentication
+
+The tool automatically uses authentication from:
+
+1. **GitHub CLI** (`gh auth token`) - if you're logged in with `gh auth login`
+2. **Environment variable** - `GITHUB_TOKEN` or `GH_TOKEN`
+
+No manual token configuration needed if you use the GitHub CLI!
+
+## Configuration
+
+Create a configuration file (start with `config.example.yaml`):
+
+```bash
+cp config.example.yaml my-config.yaml
+```
+
+Edit to define your rules:
+
+```yaml
+rules:
+ # Archive Dependabot notifications
+ - name: "Archive Dependabot"
+ filters:
+ reason: "subscribed"
+ repository: "myorg/*"
+ subject_type: "PullRequest"
+ action: mark_done
+ enabled: true
+
+ # Mark CI notifications as read
+ - name: "Auto-read CI"
+ filters:
+ subject_type: "CheckSuite"
+ action: mark_read
+ enabled: true
+
+ # Archive old read notifications
+ - name: "Archive old"
+ filters:
+ unread: false
+ age_days: 7
+ action: mark_done
+ enabled: true
+```
+
+### Available Filters
+
+- **`repository`**: Repository name or pattern (e.g., `"owner/repo"` or `"owner/*"`)
+- **`reason`**: Notification reason (e.g., `"subscribed"`, `"mention"`, `"team_mention"`, `"author"`)
+- **`subject_type`**: Type of subject (e.g., `"Release"`, `"Issue"`, `"PullRequest"`, `"CheckSuite"`, `"Discussion"`)
+- **`unread`**: Boolean - `true` for unread, `false` for read
+- **`age_days`**: Number - notifications older than N days
+- **`updated_before`**: ISO date string - notifications updated before this date (YYYY-MM-DD)
+- **`updated_after`**: ISO date string - notifications updated after this date (YYYY-MM-DD)
+
+### Available Actions
+
+- **`mark_read`**: Mark notification as read (keeps in inbox)
+- **`mark_done`**: Mark notification as done (archives it)
+- **`skip`**: Don't process (useful for exclusion rules)
+
+## Usage
+
+### Validate Configuration
+
+Check your configuration file for errors:
+
+```bash
+github-notif-manager validate --config config.yaml
+```
+
+### List Notifications
+
+View your recent notifications (useful for testing filters):
+
+```bash
+github-notif-manager list --limit 20
+```
+
+### Process Notifications (Dry Run)
+
+Test your rules without making changes:
+
+```bash
+github-notif-manager process --config config.yaml --dry-run
+```
+
+### Process Notifications (Live)
+
+Apply rules to your notifications:
+
+```bash
+github-notif-manager process --config config.yaml
+```
+
+### Verbose Output
+
+Show all processed notifications including skipped ones:
+
+```bash
+github-notif-manager process --config config.yaml --dry-run --verbose
+```
+
+## Examples
+
+### Archive All Dependabot Notifications
+
+```yaml
+rules:
+ - name: "Archive Dependabot"
+ filters:
+ reason: "subscribed"
+ repository: "*/*" # All repositories
+ action: mark_done
+```
+
+### Mark CI Notifications as Read from Specific Org
+
+```yaml
+rules:
+ - name: "Auto-read org CI"
+ filters:
+ repository: "myorg/*"
+ subject_type: "CheckSuite"
+ action: mark_read
+```
+
+### Archive Old Read Notifications
+
+```yaml
+rules:
+ - name: "Cleanup old"
+ filters:
+ unread: false
+ age_days: 14 # Older than 2 weeks
+ action: mark_done
+```
+
+### Skip Important Repositories
+
+```yaml
+rules:
+ # This rule should come BEFORE other broader rules
+ - name: "Keep important notifications"
+ filters:
+ repository: "myorg/critical-repo"
+ action: skip
+
+ - name: "Archive everything else from org"
+ filters:
+ repository: "myorg/*"
+ action: mark_done
+```
+
+### Complex Multi-Filter Rule
+
+```yaml
+rules:
+ - name: "Archive old subscribed PRs"
+ filters:
+ repository: "myorg/*"
+ subject_type: "PullRequest"
+ reason: "subscribed"
+ unread: false
+ age_days: 3
+ action: mark_done
+```
+
+## Automation
+
+### Daily Cron Job
+
+Add to your crontab:
+
+```bash
+# Run daily at 2 AM
+0 2 * * * /path/to/github-notif-manager process --config /path/to/config.yaml
+```
+
+### Systemd Timer
+
+Create `~/.config/systemd/user/github-notif-manager.service`:
+
+```ini
+[Unit]
+Description=GitHub Notification Manager
+After=network-online.target
+
+[Service]
+Type=oneshot
+WorkingDirectory=%h/src/home/tools/github-notif-manager
+ExecStart=%h/src/home/tools/github-notif-manager/target/release/github-notif-manager process --config config.yaml
+```
+
+Create `~/.config/systemd/user/github-notif-manager.timer`:
+
+```ini
+[Unit]
+Description=Run GitHub Notification Manager daily
+
+[Timer]
+OnCalendar=daily
+Persistent=true
+
+[Install]
+WantedBy=timers.target
+```
+
+Enable and start:
+
+```bash
+systemctl --user enable --now github-notif-manager.timer
+```
+
+## Tips
+
+1. **Test with dry-run first**: Always test your rules with `--dry-run` before running live
+2. **Start with specific rules**: Begin with narrow filters and expand as needed
+3. **Use skip for exclusions**: Place exclusion rules (with `skip` action) before broader rules
+4. **Check notification reasons**: Use `github-notif-manager list` to see what notification reasons you receive
+5. **Monitor API rate limits**: The tool respects GitHub API rate limits, but be aware when running frequently
+
+## Troubleshooting
+
+### "No GitHub token found"
+
+Either:
+- Run `gh auth login` to authenticate with GitHub CLI, or
+- Set the `GITHUB_TOKEN` environment variable: `export GITHUB_TOKEN="your_token"`
+
+### "Configuration file not found"
+
+Specify the full path to your config file:
+```bash
+github-notif-manager process --config ~/path/to/config.yaml
+```
+
+### Rules not matching as expected
+
+Use `--verbose --dry-run` to see all notifications and which rules match:
+```bash
+github-notif-manager process --config config.yaml --dry-run --verbose
+```
+
+## Development
+
+Build:
+```bash
+cargo build
+```
+
+Run tests:
+```bash
+cargo test
+```
+
+Format code:
+```bash
+cargo fmt
+```
+
+## License
+
+MIT