Commit 1761f389a942

Vincent Demeester <vincent@sbr.pm>
2026-02-11 06:15:59
feat: add github-notif-manager tool
Rule-based GitHub notification automation in Rust. Supports configurable YAML rules to mark notifications as read or done based on repository, reason, type, and age. Uses gh auth token for authentication, caches notifications for incremental fetching, and provides TTY-aware output for both interactive and automated use.
1 parent 93e4a90
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(&notification.id),
+            Action::MarkDone => self.client.mark_thread_done(&notification.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(&notification.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(&notif.repository.full_name, 35)).fg(Color::Cyan),
+            Cell::new(&notif.subject.subject_type).fg(Color::Magenta),
+            Cell::new(&notif.reason).fg(Color::Yellow),
+            Cell::new(truncate(&notif.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(&notification.reason, value),
+                "subject_type" => Self::match_string(&notification.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(&notification.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