Skip to content

Commit ed9e913

Browse files
committed
fix: Scope reply commands to the nearest report only
1 parent a762ecc commit ed9e913

5 files changed

Lines changed: 359 additions & 127 deletions

File tree

src/bone/commands.clj

Lines changed: 89 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -348,14 +348,32 @@
348348
(re-find vote-down-pattern body-text) :down
349349
(re-find vote-null-pattern body-text) :null)))
350350

351+
(defn vote-tx
352+
"Pure: the transaction map recording `vote` for the voter, or nil
353+
when it matches `current` (a no-op revote). One entity per voter
354+
per report: a revote carries the `existing` entity id explicitly --
355+
no upsert through the unique attr at transact time."
356+
[{:keys [vote-key report-eid email-eid voter-addr existing current vote]}]
357+
(when (not= current vote)
358+
(if existing
359+
{:db/id existing
360+
:vote/email email-eid
361+
:vote/value vote}
362+
{:vote/key vote-key
363+
:vote/report report-eid
364+
:vote/email email-eid
365+
:vote/value vote
366+
:vote/voter voter-addr})))
367+
351368
;; ---------------------------------------------------------------------------
352369
;; Command application (effectful)
353370
;; ---------------------------------------------------------------------------
354371

355372
(defn- ref-eid [v] (if (map? v) (:db/id v) v))
356373

357374
(defn- apply-vote!
358-
"Record a pre-detected `vote` (caller runs `detect-vote`)."
375+
"Record a pre-detected `vote` (caller runs `detect-vote`); the tx
376+
itself is built by the pure `vote-tx` (last vote wins)."
359377
[conn report-eid from-addr vote email delivery source-cfg]
360378
(if-not (common/sent-via-source-channel? delivery source-cfg)
361379
(log/info "Vote ignored (private email on public source)" from-addr)
@@ -365,22 +383,17 @@
365383
;; keyed on the mid hash (unique attr, raw mids unbounded).
366384
addr (some-> from-addr str/lower-case)
367385
vote-key (str (common/mid-hash report-mid) ":" addr)
368-
;; One entity per voter per report; a new vote replaces the
369-
;; previous one (last vote wins). Explicit :db/id on revote
370-
;; -- no upsert through the unique attr at transact time.
371386
existing (d/entid db [:vote/key vote-key])
372387
current (when existing
373388
(:vote/value (d/pull db [:vote/value] existing)))]
374-
(when (not= current vote)
375-
(d/transact! conn [(if existing
376-
{:db/id existing
377-
:vote/email (:db/id email)
378-
:vote/value vote}
379-
{:vote/key vote-key
380-
:vote/report report-eid
381-
:vote/email (:db/id email)
382-
:vote/value vote
383-
:vote/voter addr})])
389+
(when-let [tx (vote-tx {:vote-key vote-key
390+
:report-eid report-eid
391+
:email-eid (:db/id email)
392+
:voter-addr addr
393+
:existing existing
394+
:current current
395+
:vote vote})]
396+
(d/transact! conn [tx])
384397
(tracking/bump-report-updated! conn report-eid)
385398
(log/info (cond-> (str "Vote " (case vote :up "+1" :down "-1" "0")
386399
" by " from-addr)
@@ -650,26 +663,36 @@
650663
:unset-related (str syntax ": " target-message-id)
651664
syntax)))
652665

666+
(defn partition-lines-by-scope
667+
"Pure decision behind `filter-permitted-lines`: split the `line-pred`
668+
subset of `lines` into {:allowed [...] :denied [...]} by whether the
669+
effective scope permits `from-addr` to act (`current-d` as in
670+
`scope-permits?` -- a realized snapshot makes this side-effect
671+
free)."
672+
[lines current-d from-addr is-maintainer? line-pred]
673+
(let [{allowed true denied false}
674+
(group-by (fn [{:keys [scope attr]}]
675+
(boolean (scope-permits? scope attr from-addr
676+
is-maintainer? current-d)))
677+
(filterv line-pred lines))]
678+
{:allowed (vec allowed) :denied (vec denied)}))
679+
653680
(defn- filter-permitted-lines
654-
"Subset of `lines` passing `line-pred` whose scope permits
655-
`from-addr` to act (`current-d` as in `scope-permits?`). With a
656-
non-nil `failure-ctx`, scope-rejected lines are recorded as
657-
:insufficient-scope failures, audience :maintainers."
681+
"Effectful shell over `partition-lines-by-scope`: with a non-nil
682+
`failure-ctx`, each denied line is recorded as an
683+
:insufficient-scope failure (audience :maintainers); the allowed
684+
lines are returned."
658685
[lines current-d from-addr is-maintainer? line-pred failure-ctx]
659-
;; Eager realization via `filterv` so the recording side effect in
660-
;; the denial branch fires deterministically, regardless of whether
661-
;; callers seq or reduce over the result.
662-
(filterv (fn [{:keys [scope attr] :as line}]
663-
(and (line-pred line)
664-
(or (scope-permits? scope attr from-addr is-maintainer? current-d)
665-
(do (when failure-ctx
666-
(record-failure!
667-
(assoc failure-ctx
668-
:reason :insufficient-scope
669-
:audience :maintainers
670-
:command (describe-denied-line line))))
671-
false))))
672-
lines))
686+
(let [{:keys [allowed denied]}
687+
(partition-lines-by-scope lines current-d from-addr
688+
is-maintainer? line-pred)]
689+
(when failure-ctx
690+
(doseq [line denied]
691+
(record-failure! (assoc failure-ctx
692+
:reason :insufficient-scope
693+
:audience :maintainers
694+
:command (describe-denied-line line)))))
695+
allowed))
673696

674697
(defn- report-eid-by-mid
675698
"Report eid for `target-mid`, matching the root or any thread
@@ -1121,32 +1144,43 @@
11211144
[attr]
11221145
(some-> (attr->word-cmd attr) :id name str/capitalize (str ".")))
11231146

1147+
(defn partition-words-by-scope
1148+
"Pure decision behind `filter-words-by-scope`: split `word-result`
1149+
into {:allowed <word-map-or-nil> :denied [attr ...]} by each
1150+
bareword's effective scope. :report/close-reason is no bareword:
1151+
it rides back onto a non-empty allowed map (build-word-tx only
1152+
consumes it when :report/closed is set)."
1153+
[word-result overrides is-maintainer?]
1154+
(let [{allowed true denied false}
1155+
(group-by (fn [[attr _]]
1156+
(let [cmd (attr->word-cmd attr)
1157+
scope (or (:scope (get overrides (:id cmd))) (:scope cmd))]
1158+
(word-scope-permits? scope is-maintainer?)))
1159+
(dissoc word-result :report/close-reason))
1160+
allowed-map (into {} allowed)]
1161+
{:allowed (when (seq allowed-map)
1162+
(cond-> allowed-map
1163+
(:report/close-reason word-result)
1164+
(assoc :report/close-reason (:report/close-reason word-result))))
1165+
:denied (mapv key denied)}))
1166+
11241167
(defn- filter-words-by-scope
1125-
"Filter `word-result` to the subset allowed by the effective scope
1126-
for each bareword. When `failure-ctx` is non-nil, barewords that fail
1127-
the scope check are recorded as :insufficient-scope failures with
1128-
\":audience :maintainers\", so denied attempts surface to maintainer
1129-
subscribers via the notification loop."
1168+
"Effectful shell over `partition-words-by-scope`: with a non-nil
1169+
`failure-ctx`, each denied bareword is recorded as an
1170+
:insufficient-scope failure (audience :maintainers, so denied
1171+
attempts surface to maintainer subscribers via the notification
1172+
loop); the allowed map is returned."
11301173
[word-result overrides is-maintainer? failure-ctx]
11311174
(when word-result
1132-
(let [filtered (into {}
1133-
(keep (fn [[attr :as entry]]
1134-
(let [cmd (attr->word-cmd attr)
1135-
scope (or (:scope (get overrides (:id cmd))) (:scope cmd))]
1136-
(if (word-scope-permits? scope is-maintainer?)
1137-
entry
1138-
(do (when failure-ctx
1139-
(record-failure!
1140-
(assoc failure-ctx
1141-
:reason :insufficient-scope
1142-
:audience :maintainers
1143-
:command (describe-denied-word attr))))
1144-
nil)))))
1145-
(dissoc word-result :report/close-reason))]
1146-
(when (seq filtered)
1147-
(cond-> filtered
1148-
(:report/close-reason word-result) (assoc :report/close-reason
1149-
(:report/close-reason word-result)))))))
1175+
(let [{:keys [allowed denied]}
1176+
(partition-words-by-scope word-result overrides is-maintainer?)]
1177+
(when failure-ctx
1178+
(doseq [attr denied]
1179+
(record-failure! (assoc failure-ctx
1180+
:reason :insufficient-scope
1181+
:audience :maintainers
1182+
:command (describe-denied-word attr)))))
1183+
allowed)))
11501184

11511185
;; Carrier-eligible commands: when a mail simultaneously creates a new
11521186
;; report AND carries one of these annotations, the annotation applies

src/bone/digest.clj

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@
5050
(:email/in-reply-to email)))
5151

5252
(defn- reports-by-hash
53-
"Report eids whose root or any descendant has the mid-hash `h`.
53+
"Reports matched by the mid-hash `h`: {:root report-eid-or-nil
54+
:encl #{eids}} -- :root when `h` is a report's own message-id,
55+
:encl the reports counting that email among their descendants.
5456
Resolution via bone.lookup, descendant join eid-bound (see the
5557
bone.lookup ns docstring)."
5658
[db h]
@@ -59,7 +61,7 @@
5961
as-desc (when email-e
6062
(d/q '[:find [?r ...] :in $ ?e :where [?r :report/descendants ?e]]
6163
db email-e))]
62-
(cond-> (set as-desc) as-root (conj as-root))))
64+
{:root as-root :encl (set as-desc)}))
6365

6466
(defn- email-ancestors
6567
"Ancestor mids of the stored email `eid` (root first). Used by
@@ -74,10 +76,42 @@
7476
"Upper bound on transitive ancestor splicing per `thread-lookup` call."
7577
32)
7678

79+
(defn thread-step
80+
"Pure arbitration of one ancestor mid during `thread-lookup`, walked
81+
nearest-first. `state` is {:all #{eids} :nearest nil-or-#{eids}
82+
:cand nil-or-#{eids}} -- :cand holds the enclosing reports of the
83+
closest hit while no root has decided. `hit` is `reports-by-hash`'s
84+
{:root :encl}. A root decides :nearest: the root itself when the
85+
undecided set contains it (or there is none) -- a reply to a patch
86+
targets the patch, never the cover letter (or bug) whose thread
87+
merely encloses it -- else the undecided set, which belongs to a
88+
closer thread. Returns the next state."
89+
[{:keys [nearest cand] :as state} {:keys [root encl]}]
90+
(let [eids (cond-> (or encl #{}) root (conj root))
91+
nearest' (or nearest
92+
(cond
93+
(and root (or (nil? cand) (contains? cand root))) #{root}
94+
root cand
95+
:else nil))
96+
cand' (if (and (nil? nearest') (nil? cand) (seq encl)) encl cand)]
97+
(-> state
98+
(update :all into eids)
99+
(assoc :nearest nearest' :cand cand'))))
100+
101+
(defn finalize-thread-lookup
102+
"Pure: end of the walk -- an undecided candidate set becomes the
103+
:nearest (no ancestor ever resolved as a root), and the working
104+
:cand key is dropped."
105+
[{:keys [nearest cand] :as state}]
106+
(-> state
107+
(assoc :nearest (or nearest cand))
108+
(dissoc :cand)))
109+
77110
(defn thread-lookup
78111
"Walk `email`'s ancestor mids nearest-first. Returns
79112
`{:all #{eids} :nearest #{eids}}` -- :all is every report matched
80-
by any ancestor, :nearest is the closest match (nil if none).
113+
by any ancestor, :nearest is the closest match (nil if none), both
114+
arbitrated by the pure `thread-step`/`finalize-thread-lookup`.
81115
When an ancestor mid matches a stored email with no report, that
82116
email's own ancestors are spliced into the walk (bounded by
83117
`thread-lookup-max-splices`) so pending intermediates don't orphan
@@ -86,26 +120,24 @@
86120
(loop [stack (vec (ancestor-mids email)) ; root-first; peek = nearest
87121
seen #{}
88122
splices 0
89-
acc {:all #{} :nearest nil}]
123+
state {:all #{} :nearest nil :cand nil}]
90124
(if (empty? stack)
91-
acc
125+
(finalize-thread-lookup state)
92126
(let [mid (peek stack)
93127
stack' (pop stack)]
94128
(if (contains? seen mid)
95-
(recur stack' seen splices acc)
96-
(let [seen' (conj seen mid)
97-
h (common/mid-hash mid)
98-
eids (reports-by-hash db h)
99-
acc' (cond-> acc
100-
(seq eids) (update :all into eids)
101-
(and (seq eids) (nil? (:nearest acc))) (assoc :nearest eids))]
102-
(if (and (empty? eids)
129+
(recur stack' seen splices state)
130+
(let [seen' (conj seen mid)
131+
h (common/mid-hash mid)
132+
{:keys [root encl] :as hit} (reports-by-hash db h)
133+
state' (thread-step state hit)]
134+
(if (and (nil? root) (empty? encl)
103135
(< splices thread-lookup-max-splices))
104136
(if-let [ancestors (some->> (lookup/email-eid-by-hash db h)
105137
(email-ancestors db))]
106-
(recur (into stack' ancestors) seen' (inc splices) acc')
107-
(recur stack' seen' splices acc'))
108-
(recur stack' seen' splices acc'))))))))
138+
(recur (into stack' ancestors) seen' (inc splices) state')
139+
(recur stack' seen' splices state'))
140+
(recur stack' seen' splices state'))))))))
109141

110142
;; ---------------------------------------------------------------------------
111143
;; DB operations

0 commit comments

Comments
 (0)