Commit 98e86b9556e9

Vincent Demeester <vincent@sbr.pm>
2026-02-11 09:35:28
feat(github-notif-manager): added subject_title filter
Enhanced notification filtering and cache management to better handle done notifications and support matching on PR titles. - Added subject_title filter with wildcard pattern matching - Improved cache done tracking with filtered count reporting - Added --clear-done flag to reset done notification IDs - Created rule to auto-archive merged Dependabot PRs - Updated documentation with new filter and examples
1 parent af05f2b
dots/config/github-notif-manager/config.yaml
@@ -57,6 +57,16 @@ rules:
 
   # ─── TARGETED ARCHIVE rules — before the general keep ─────────────────
 
+  # Merged Dependabot PRs (titles: "Bump ..." or "chore(deps): bump ...")
+  - name: "Archive merged Dependabot PRs"
+    description: "Merged dependency update PRs from dependabot"
+    filters:
+      subject_type: "PullRequest"
+      reason: "state_change"
+      subject_title: "*bump *"
+    action: done
+    enabled: true
+
   # openshift-pipelines/operator review requests (437 notifications!)
   # These are almost all automated bot/renovate PRs
   - name: "Archive operator bot review requests"
tools/github-notif-manager/src/cache.rs
@@ -61,13 +61,18 @@ impl Cache {
 
     /// 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) {
+    /// Returns (fresh_count, filtered_count) - number of notifications received and filtered.
+    pub fn merge(&mut self, fresh: Vec<Notification>, full_fetch: bool) -> (usize, usize) {
+        let fresh_count = fresh.len();
+        
         // 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();
 
+        let filtered_count = fresh_count - fresh.len();
+
         if full_fetch {
             self.notifications = fresh;
         } else {
@@ -85,6 +90,7 @@ impl Cache {
         }
 
         self.last_fetched = Some(Utc::now());
+        (fresh_count, filtered_count)
     }
 
     /// Mark a notification as done — removes from notifications and tracks ID
@@ -102,4 +108,9 @@ impl Cache {
     pub fn done_count(&self) -> usize {
         self.done_ids.len()
     }
+
+    /// Clear all done IDs from the cache
+    pub fn clear_done_ids(&mut self) {
+        self.done_ids.clear();
+    }
 }
tools/github-notif-manager/src/config.rs
@@ -57,6 +57,7 @@ impl Config {
             "repository",
             "reason",
             "subject_type",
+            "subject_title",
             "unread",
             "age_days",
             "updated_before",
tools/github-notif-manager/src/main.rs
@@ -80,9 +80,13 @@ enum Commands {
         #[arg(long)]
         cache_dir: Option<PathBuf>,
 
-        /// Clear the cache
+        /// Clear the entire cache
         #[arg(long)]
         clear: bool,
+
+        /// Clear only the done IDs (keep notifications)
+        #[arg(long)]
+        clear_done: bool,
     },
 }
 
@@ -103,7 +107,7 @@ fn main() -> Result<()> {
             all,
             cache_dir,
         } => cmd_list(limit, all, cache_dir),
-        Commands::Cache { cache_dir, clear } => cmd_cache(cache_dir, clear),
+        Commands::Cache { cache_dir, clear, clear_done } => cmd_cache(cache_dir, clear, clear_done),
     }
 }
 
@@ -171,27 +175,46 @@ fn fetch_notifications(
         .get_notifications(force_all, since.as_deref(), pb.as_ref())
         .context("Failed to fetch notifications")?;
 
-    let fresh_count = fresh.len();
-    cache.merge(fresh, full_fetch);
+    let (fresh_count, filtered_count) = 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()
-        );
+        let msg = if filtered_count > 0 {
+            format!(
+                "  {} Fetched {} notifications (filtered {} previously done)",
+                if tty { "✓".green().to_string() } else { "OK".to_string() },
+                cache.notifications.len(),
+                filtered_count
+            )
+        } else {
+            format!(
+                "  {} Fetched {} notifications",
+                if tty { "✓".green().to_string() } else { "OK".to_string() },
+                cache.notifications.len()
+            )
+        };
+        eprintln!("{}", msg);
     } else {
-        eprintln!(
-            "  {} Fetched {} new/updated, {} total cached",
-            if tty { "✓".green().to_string() } else { "OK".to_string() },
-            fresh_count,
-            cache.notifications.len()
-        );
+        let msg = if filtered_count > 0 {
+            format!(
+                "  {} Fetched {} new/updated (filtered {} done), {} total cached",
+                if tty { "✓".green().to_string() } else { "OK".to_string() },
+                fresh_count - filtered_count,
+                filtered_count,
+                cache.notifications.len()
+            )
+        } else {
+            format!(
+                "  {} Fetched {} new/updated, {} total cached",
+                if tty { "✓".green().to_string() } else { "OK".to_string() },
+                fresh_count,
+                cache.notifications.len()
+            )
+        };
+        eprintln!("{}", msg);
     }
 
     Ok(())
@@ -499,7 +522,7 @@ fn cmd_list(limit: usize, force_all: bool, cache_dir: Option<PathBuf>) -> Result
     Ok(())
 }
 
-fn cmd_cache(cache_dir: Option<PathBuf>, clear: bool) -> Result<()> {
+fn cmd_cache(cache_dir: Option<PathBuf>, clear: bool, clear_done: bool) -> Result<()> {
     let cp = cache_path(cache_dir);
 
     if clear {
@@ -512,6 +535,23 @@ fn cmd_cache(cache_dir: Option<PathBuf>, clear: bool) -> Result<()> {
         return Ok(());
     }
 
+    if clear_done {
+        if !cp.exists() {
+            println!("{}", "No cache file found".yellow());
+            return Ok(());
+        }
+        let mut cache = Cache::load(&cp)?;
+        let count = cache.done_count();
+        cache.clear_done_ids();
+        cache.save(&cp)?;
+        println!(
+            "{} Cleared {} done notification IDs from cache",
+            "✓".green(),
+            count
+        );
+        return Ok(());
+    }
+
     if !cp.exists() {
         println!("{}", "No cache file found".yellow());
         println!("Path: {}", cp.display());
@@ -545,7 +585,11 @@ fn cmd_cache(cache_dir: Option<PathBuf>, clear: bool) -> Result<()> {
         println!("  Read:   {}", read.to_string().bright_black());
     }
 
-    println!("Done tracked: {}", cache.done_count());
+    println!(
+        "Done tracked:  {} {}",
+        cache.done_count().to_string().yellow(),
+        "(use --clear-done to reset)".bright_black()
+    );
 
     Ok(())
 }
tools/github-notif-manager/src/rules.rs
@@ -13,6 +13,7 @@ impl RuleMatcher {
                 "repository" => Self::match_repository(notification, value),
                 "reason" => Self::match_string(&notification.reason, value),
                 "subject_type" => Self::match_string(&notification.subject.subject_type, value),
+                "subject_title" => Self::match_subject_title(notification, value),
                 "unread" => Self::match_bool(notification.unread, value),
                 "age_days" => Self::match_age_days(notification, value),
                 "updated_before" => Self::match_updated_before(notification, value),
@@ -50,6 +51,15 @@ impl RuleMatcher {
         }
     }
 
+    fn match_subject_title(notification: &Notification, value: &FilterValue) -> bool {
+        if let FilterValue::String(pattern) = value {
+            let matcher = WildMatch::new_case_insensitive(pattern);
+            matcher.matches(&notification.subject.title)
+        } else {
+            false
+        }
+    }
+
     fn match_string(field_value: &str, filter_value: &FilterValue) -> bool {
         if let FilterValue::String(expected) = filter_value {
             field_value == expected
tools/github-notif-manager/Cargo.lock
@@ -405,7 +405,7 @@ dependencies = [
 
 [[package]]
 name = "github-notif-manager"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "anyhow",
  "chrono",
tools/github-notif-manager/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "github-notif-manager"
-version = "0.2.0"
+version = "0.3.0"
 edition = "2021"
 
 [dependencies]
tools/github-notif-manager/config.example.yaml
@@ -4,6 +4,16 @@
 # Rules are evaluated in order, and the first matching rule is applied.
 
 rules:
+  # Example: Archive merged Dependabot PRs
+  - name: "Archive merged Dependabot PRs"
+    description: "Auto-archive merged dependency update PRs"
+    filters:
+      subject_type: "PullRequest"
+      reason: "state_change"     # Triggered when PR is merged/closed
+      subject_title: "*bump *"   # Case-insensitive; matches "Bump ..." and "chore(deps): bump ..."
+    action: done
+    enabled: true
+
   # Example: Archive all Dependabot notifications from a specific repo
   - name: "Archive Dependabot notifications"
     description: "Auto-archive dependency update notifications"
@@ -48,8 +58,9 @@ rules:
 
 # Available filter fields:
 # - repository: string or pattern (supports wildcards like "owner/*")
-# - reason: string (subscribed, mention, team_mention, author, etc.)
+# - reason: string (subscribed, mention, team_mention, author, state_change, review_requested, etc.)
 # - subject_type: string (Release, Issue, PullRequest, CheckSuite, Discussion, etc.)
+# - subject_title: string or pattern (supports wildcards, case-insensitive, e.g. "*bump *")
 # - unread: boolean (true/false)
 # - age_days: number (notifications older than N days)
 # - updated_before: ISO date string (YYYY-MM-DD)
tools/github-notif-manager/config.yaml
@@ -57,6 +57,16 @@ rules:
 
   # ─── TARGETED ARCHIVE rules — before the general keep ─────────────────
 
+  # Merged Dependabot PRs (titles: "Bump ..." or "chore(deps): bump ...")
+  - name: "Archive merged Dependabot PRs"
+    description: "Merged dependency update PRs from dependabot"
+    filters:
+      subject_type: "PullRequest"
+      reason: "state_change"
+      subject_title: "*bump *"
+    action: done
+    enabled: true
+
   # openshift-pipelines/operator review requests (437 notifications!)
   # These are almost all automated bot/renovate PRs
   - name: "Archive operator bot review requests"
tools/github-notif-manager/default.nix
@@ -7,7 +7,7 @@
 
 rustPlatform.buildRustPackage (finalAttrs: {
   pname = "github-notif-manager";
-  version = "0.2.0";
+  version = "0.3.0";
 
   src = lib.fileset.toSource {
     root = ./.;
tools/github-notif-manager/README.md
@@ -75,8 +75,9 @@ rules:
 ### Available Filters
 
 - **`repository`**: Repository name or pattern (e.g., `"owner/repo"` or `"owner/*"`)
-- **`reason`**: Notification reason (e.g., `"subscribed"`, `"mention"`, `"team_mention"`, `"author"`)
+- **`reason`**: Notification reason (e.g., `"subscribed"`, `"mention"`, `"team_mention"`, `"author"`, `"state_change"`, `"review_requested"`)
 - **`subject_type`**: Type of subject (e.g., `"Release"`, `"Issue"`, `"PullRequest"`, `"CheckSuite"`, `"Discussion"`)
+- **`subject_title`**: Pattern matching on notification title (e.g., `"*bump *"`) - supports wildcards, **case-insensitive**
 - **`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)
@@ -132,6 +133,18 @@ github-notif-manager process --config config.yaml --dry-run --verbose
 
 ## Examples
 
+### Archive Merged Dependabot PRs
+
+```yaml
+rules:
+  - name: "Archive merged Dependabot PRs"
+    filters:
+      subject_type: "PullRequest"
+      reason: "state_change"
+      subject_title: "*bump *"  # Case-insensitive; matches "Bump ..." and "chore(deps): bump ..."
+    action: done
+```
+
 ### Archive All Dependabot Notifications
 
 ```yaml