Skip to content

Commit b5357e6

Browse files
committed
feat: Filter notifications by priority, dedupe failures
1 parent 3fea4ba commit b5357e6

2 files changed

Lines changed: 132 additions & 62 deletions

File tree

docs/bark-manual.org

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,6 @@ To undo a cross-reference, reply with =Not related-to:
227227
<other-message-id@example.com>=.
228228

229229
** Resolving a bug or request with a patch
230-
:PROPERTIES:
231-
:CUSTOM_ID: resolving-a-bug-or-request-with-a-patch
232-
:END:
233230

234231
Replying to an open bug or request with a =.patch= or =.diff=
235232
attachment credits you as =Acked= and =Owned= on that parent.
@@ -1081,18 +1078,28 @@ a recipient on three sources receives three separate emails per
10811078

10821079
Optional per-subscription filters:
10831080

1084-
| Key | Description |
1085-
|-----------------+--------------------------------------------|
1086-
| =:min-priority= | Skip reports below this priority (0--3) |
1087-
| =:min-status= | Skip reports below this activity score |
1088-
| =:subject-match= | Substring match on the report subject |
1089-
| =:topic= | Substring match on the report topic |
1081+
| Key | Description |
1082+
|----------------+--------------------------------------------------------------|
1083+
| =:min-priority= | Min priority for the "unacked & unowned" section (default 1) |
1084+
| =:min-status= | Min activity score for the "unacked & unowned" section |
1085+
| =:subject-match= | Substring match on the report subject (all sections) |
1086+
| =:topic= | Substring match on the report topic (all sections) |
1087+
1088+
=:min-priority= and =:min-status= only filter the "unacked &
1089+
unowned" section -- the section with the most volume. Reports you
1090+
own (with or without deadline) are always listed regardless of
1091+
their priority or status.
10901092

10911093
=bb notify= does not track per-recipient cadence: it sends every
10921094
time it is invoked. Schedule it from cron or a systemd timer.
10931095
=bb test-config= verifies that every =:source= named under
10941096
=:subscribers= matches an existing source =:name=.
10951097

1098+
A small operational state file, =public/.last-notify-failures.edn=,
1099+
records the last successful send per (subscriber, source) so the
1100+
same failed-command entries are not re-mailed at every run. Delete
1101+
the file to replay all current failures on the next invocation.
1102+
10961103
** Logging
10971104

10981105
#+begin_src edn

scripts/bark-notify.clj

Lines changed: 116 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@
99
;; and sends emails via SMTP. The cadence is the operator's
1010
;; responsibility: schedule bb notify from cron or a systemd timer.
1111
;;
12+
;; Operational state lives in public/.last-notify-failures.edn -- a
13+
;; per-(subscriber, source) timestamp of the last successful send,
14+
;; used to avoid re-mailing the same failures. Delete the file to
15+
;; replay all failures on the next run.
16+
;;
1217
;; Usage:
1318
;; bb notify -- send notifications
1419
;; bb notify --dry-run -- show what would be sent without sending
1520
;; bb notify --debug -- verbose diagnostics
1621

1722
(require '[babashka.pods :as pods]
1823
'[clojure.string :as str]
24+
'[clojure.edn :as edn]
25+
'[clojure.java.io :as io]
1926
'[taoensso.timbre :as log]
2027
'[bark.common :refer [get-header format-date format-date-iso
2128
report-priority report-status report-descendant-count
@@ -30,6 +37,43 @@
3037

3138
(require '[pod.tzzh.mail :as mail])
3239

40+
;; ---------------------------------------------------------------------------
41+
;; Per-(subscriber, source) failures-shown tracking
42+
;; ---------------------------------------------------------------------------
43+
;;
44+
;; We persist the timestamp of the last successful notification per
45+
;; (subscriber, source) pair to avoid re-sending the same failure on
46+
;; every run. This is operational state, not configuration -- subscriber
47+
;; identity and filters live in config.edn.
48+
49+
(def ^:private last-failures-file "public/.last-notify-failures.edn")
50+
51+
(defn- load-last-failures-shown
52+
"Read {key -> epoch-millis} from the state file, or {}. A corrupt
53+
file would re-spam every subscriber with old failures, so log at warn
54+
before returning the empty default."
55+
[]
56+
(let [f (io/file last-failures-file)]
57+
(if (.exists f)
58+
(try (edn/read-string (slurp f))
59+
(catch Exception e
60+
(log/warn "Could not parse" last-failures-file
61+
"-- starting from empty state. Subscribers may"
62+
"see previously-shown failures on this run."
63+
(.getMessage e))
64+
{}))
65+
{})))
66+
67+
(defn- save-last-failures-shown!
68+
[m]
69+
(io/make-parents last-failures-file)
70+
(spit last-failures-file (pr-str m)))
71+
72+
(defn- failures-key
73+
"Key under which we track the last-shown timestamp for a subscription."
74+
[email source]
75+
(str source ":" (str/lower-case email)))
76+
3377
;; ---------------------------------------------------------------------------
3478
;; Failure queries
3579
;; ---------------------------------------------------------------------------
@@ -43,18 +87,21 @@
4387
(.getMessage e)))))
4488

4589
(defn- failures-for-subscriber
46-
"Return failures on `source` routed to `email-addr`.
90+
"Return failures on `source` routed to `email-addr`, posted after
91+
`since-date` (or all of them if since-date is nil).
4792
4893
Routing is driven by the `:audience` field on each failure entry:
4994
- `:author` -- shown only to the address that triggered the
5095
failure (someone seeing their own typo); default
5196
for legacy entries that predate the field.
5297
- `:maintainers` -- shown to every subscriber on the source."
53-
[all-failures email-addr source]
98+
[all-failures email-addr source since-date]
5499
(let [addr (str/lower-case email-addr)]
55100
(->> all-failures
56-
(filter (fn [{:keys [from audience] src :source}]
101+
(filter (fn [{:keys [from audience date] src :source}]
57102
(and (= source src)
103+
(or (nil? since-date)
104+
(and date (.after ^java.util.Date date since-date)))
58105
(case (or audience :author)
59106
:author (= addr from)
60107
:maintainers true
@@ -134,18 +181,15 @@
134181
"\n")))
135182

136183
(defn- filter-relevant-reports
137-
"Filter the full report set against a subscription's filters: actionable
138-
type, open, on-source, meets min-priority and min-status, and (optionally)
139-
the :subject-match / :topic substring filters."
140-
[reports {:keys [source min-pri min-sts subj-match topic]}]
184+
"Filter the full report set against scope filters: actionable type,
185+
open, on-source, plus optional :subject-match / :topic substrings."
186+
[reports {:keys [source subj-match topic]}]
141187
(let [subj-lc (some-> subj-match str/lower-case)
142188
topic-lc (some-> topic str/lower-case)]
143189
(cond->> reports
144190
true (filter #(contains? actionable-types (:report/type %)))
145191
true (filter open?)
146192
true (filter #(= source (get-in % [:report/email :email/source])))
147-
true (filter #(>= (report-priority %) min-pri))
148-
true (filter #(>= (report-status %) min-sts))
149193
subj-lc (filter #(some-> (get-in % [:report/email :email/subject])
150194
str/lower-case
151195
(str/includes? subj-lc)))
@@ -154,8 +198,11 @@
154198
(str/includes? topic-lc))))))
155199

156200
(defn- build-sections
157-
"Group relevant reports into the three body sections."
158-
[relevant email]
201+
"Group relevant reports into the three body sections. Sections 1
202+
and 2 (owned by you, with or without deadline) always show what you
203+
own. Section 3 (unacked & unowned) is the noisy one and is the
204+
only one filtered by min-priority and min-status."
205+
[relevant email {:keys [min-pri min-sts]}]
159206
(let [owned (filter #(owned-by? % email) relevant)]
160207
{:dl (->> owned
161208
(filter :report/deadline-value)
@@ -165,7 +212,9 @@
165212
(sort-by #(- (report-priority %))))
166213
:unacked (->> relevant
167214
(filter unacked?)
168-
(filter unowned?))}))
215+
(filter unowned?)
216+
(filter #(>= (report-priority %) min-pri))
217+
(filter #(>= (report-status %) min-sts)))}))
169218

170219
(defn- failure-subjects-map
171220
"Build {message-id -> subject} for the message-ids referenced by `failures`."
@@ -203,14 +252,14 @@
203252
[db reports email subscription failures]
204253
(let [source (:source subscription)
205254
prefs {:source source
206-
:min-pri (:min-priority subscription 0)
255+
:min-pri (:min-priority subscription 1)
207256
:min-sts (:min-status subscription 0)
208257
:subj-match (:subject-match subscription)
209258
:topic (:topic subscription)}
210259
relevant (->> (filter-relevant-reports reports prefs)
211260
(sort-by (juxt report-priority report-descendant-count)
212261
#(compare %2 %1)))
213-
{:keys [dl owned unacked]} (build-sections relevant email)
262+
{:keys [dl owned unacked]} (build-sections relevant email prefs)
214263
sec-fail (failures-section db source failures)
215264
sec-dl (section
216265
(str "== Upcoming deadlines -- owned by you (" source ") ==")
@@ -287,44 +336,58 @@
287336
(log/info "No :subscribers configured.")
288337
(System/exit 0))
289338
(try
290-
(let [db (d/db conn)
291-
src-map (build-source-map config)
292-
reports (all-reports db)
293-
all-failures (load-failures)
294-
pairs (expand-subscribers subscribers)
295-
_ (log/debug (count pairs) "subscription(s) configured")
296-
live-pairs (filter (fn [[_ s]] (source-notify-enabled? src-map (:source s))) pairs)
297-
_ (do (when (< (count live-pairs) (count pairs))
298-
(doseq [[email s] pairs
299-
:when (not (source-notify-enabled? src-map (:source s)))]
300-
(log/debug "SKIPPED" email
301-
"-- notifications disabled for source"
302-
(:source s))))
303-
(log/debug (count live-pairs) "after per-source filter"))
304-
sent (atom 0)]
305-
(if (empty? live-pairs)
306-
(log/info "No active subscriptions.")
307-
(doseq [[email subscription] live-pairs]
308-
(let [source (:source subscription)
309-
failures (failures-for-subscriber all-failures email source)
310-
body (build-email-body db reports email subscription failures)]
311-
(if body
312-
(do (log/info (if dry-run? "[dry-run]" "")
313-
"Notifying" email (str "(source: " source ")"))
314-
(when-not dry-run?
315-
(try
316-
(send-notification! smtp email source body)
317-
(swap! sent inc)
318-
(catch Exception e
319-
(log/error "Failed to send to" email
320-
(str "(source: " source "):")
321-
(.getMessage e)))))
322-
(when dry-run?
323-
(println "---")
324-
(println body)
325-
(println "---")))
326-
(log/info "No open items for" email
327-
(str "(source: " source "),") "skipping.")))))
328-
(log/info "Done." (if dry-run? "Dry run, no emails sent." (str @sent " email(s) sent."))))
339+
(let [db (d/db conn)
340+
src-map (build-source-map config)
341+
reports (all-reports db)
342+
all-failures (load-failures)
343+
last-shown (load-last-failures-shown)
344+
pairs (expand-subscribers subscribers)
345+
_ (log/debug (count pairs) "subscription(s) configured")
346+
live-pairs (filter (fn [[_ s]] (source-notify-enabled? src-map (:source s))) pairs)
347+
_ (do (when (< (count live-pairs) (count pairs))
348+
(doseq [[email s] pairs
349+
:when (not (source-notify-enabled? src-map (:source s)))]
350+
(log/debug "SKIPPED" email
351+
"-- notifications disabled for source"
352+
(:source s))))
353+
(log/debug (count live-pairs) "after per-source filter"))
354+
sent (atom 0)
355+
updated-shown (atom last-shown)]
356+
(try
357+
(if (empty? live-pairs)
358+
(log/info "No active subscriptions.")
359+
(doseq [[email subscription] live-pairs]
360+
(let [source (:source subscription)
361+
k (failures-key email source)
362+
since-ms (get last-shown k)
363+
since (when since-ms (java.util.Date. (long since-ms)))
364+
failures (failures-for-subscriber all-failures email source since)
365+
body (build-email-body db reports email subscription failures)]
366+
(if body
367+
(do (log/info (if dry-run? "[dry-run]" "")
368+
"Notifying" email (str "(source: " source ")"))
369+
(when-not dry-run?
370+
(try
371+
(send-notification! smtp email source body)
372+
(swap! updated-shown assoc k (.getTime (java.util.Date.)))
373+
(swap! sent inc)
374+
(catch Exception e
375+
;; Don't advance the timestamp on failure so the
376+
;; same failures are retried next run.
377+
(log/error "Failed to send to" email
378+
(str "(source: " source "):")
379+
(.getMessage e)))))
380+
(when dry-run?
381+
(println "---")
382+
(println body)
383+
(println "---")))
384+
(log/info "No open items for" email
385+
(str "(source: " source "),") "skipping.")))))
386+
(log/info "Done." (if dry-run? "Dry run, no emails sent." (str @sent " email(s) sent.")))
387+
(finally
388+
(when-not dry-run?
389+
(try (save-last-failures-shown! @updated-shown)
390+
(catch Exception e
391+
(log/error "Failed to persist last-notify-failures:" (.getMessage e))))))))
329392
(finally
330393
(d/close conn))))))

0 commit comments

Comments
 (0)