Skip to content

Commit b38b740

Browse files
committed
fix: Autosupersede patch revisions posted on sibling thread branches
1 parent 2b757a6 commit b38b740

2 files changed

Lines changed: 547 additions & 34 deletions

File tree

src/bone/digest.clj

Lines changed: 181 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,66 @@
256256
(defn- parse-version-number [v]
257257
(when v (when-let [[_ n] (re-find #"^v(\d+)$" v)] (parse-long n))))
258258

259+
(defn- open-patch-eids-by-sender
260+
"Open :patch reports from `from-addr`, excluding `report-eid`.
261+
Address comparison is case-insensitive: the attr is stored verbatim
262+
and some senders vary the casing between messages. A candidate pool
263+
that callers narrow via `shares-common-ancestor?` -- same sender
264+
alone is too weak (unrelated patches can share a sender and a
265+
generic subject)."
266+
[db report-eid from-addr]
267+
(when from-addr
268+
(d/q '[:find [?r ...]
269+
:in $ ?self ?from-lc
270+
:where
271+
[?r :report/type :patch]
272+
(not [?r :report/closed _])
273+
[(not= ?r ?self)]
274+
[?r :report/email ?e]
275+
[?e :email/author-address ?a]
276+
[(clojure.string/lower-case ?a) ?alc]
277+
[(= ?alc ?from-lc)]]
278+
db report-eid (str/lower-case from-addr))))
279+
280+
(defn- ancestor-mid-closure
281+
"All ancestor mids reachable from `email` (splicing through stored
282+
intermediates, like `thread-lookup`), plus its own message-id."
283+
[db email]
284+
(loop [stack (vec (ancestor-mids email))
285+
seen #{}
286+
splices 0]
287+
(if (empty? stack)
288+
(conj seen (:email/message-id email))
289+
(let [mid (peek stack)
290+
stack' (pop stack)]
291+
(if (contains? seen mid)
292+
(recur stack' seen splices)
293+
(let [seen' (conj seen mid)
294+
h (common/mid-hash mid)
295+
ancestors (when (< splices thread-lookup-max-splices)
296+
(some->> (lookup/email-eid-by-hash db h)
297+
(email-ancestors db)))]
298+
(recur (if ancestors (into stack' ancestors) stack')
299+
seen'
300+
(if ancestors (inc splices) splices))))))))
301+
302+
(defn- shares-common-ancestor?
303+
"True when the candidate report's own email and `email-closure` (the
304+
new email's `ancestor-mid-closure`, passed in since it is the same for
305+
every candidate) trace back to a common message -- even on different
306+
thread branches (v2 replies to v1, v3 replies to a review of v1: no
307+
direct v2<->v3 link, but both close over v1). This is what keeps the
308+
`open-patch-eids-by-sender` broadening from conflating two unrelated
309+
patches that merely share a sender and subject."
310+
[db email-closure candidate-eid]
311+
(when-let [cand-email (some-> (d/pull db '[{:report/email
312+
[:email/message-id
313+
:email/in-reply-to
314+
:email/references]}]
315+
candidate-eid)
316+
:report/email)]
317+
(boolean (some email-closure (ancestor-mid-closure db cand-email)))))
318+
259319
(defn- auto-supersede-patch!
260320
"Close `old-rid` as :superseded by `new-report-eid`, pose the
261321
:supersedes + :related-to relations, propagate auto-credit transfers."
@@ -272,34 +332,121 @@
272332
(tracking/bump-report-updated! conn old-rid)
273333
(log/info "Auto-closed patch" log-msg)))
274334

275-
(defn- close-patch-previous-version! [conn report-eid report-info email nearest-report-eids]
335+
(defn- normalize-subject
336+
"Strip Re:/Fwd: prefixes and bracketed tags to get the base subject."
337+
[subject]
338+
(when subject
339+
(-> subject
340+
(str/replace #"(?i)^(\s*(Re|Fwd)\s*:\s*)+" "")
341+
(str/replace #"\[[^\]]*\]\s*" "")
342+
str/trim
343+
str/lower-case)))
344+
345+
(def ^:private patch-pull [:patch/filename :patch/subject])
346+
347+
(defn- patch-canon
348+
"Canonical token form of a patch identifier: lowercase, drop the
349+
`.patch`/`.diff` extension, keep alphanumeric tokens, drop version and
350+
sequence tokens (`v?\\d+`), rejoin with spaces. A git filename is the
351+
commit subject with punctuation turned to dashes, so subject- and
352+
filename-derived keys converge, and neither a version bump nor a series
353+
position perturbs the key."
354+
[s]
355+
(some->> (some-> s str/lower-case (str/replace #"(?i)\.(patch|diff)$" ""))
356+
(re-seq #"[a-z0-9]+")
357+
(remove #(re-matches #"v?\d+" %))
358+
seq
359+
(str/join " ")))
360+
361+
(defn- patch-idents
362+
"Set of canonical identifiers for a report's patches, from the
363+
internal git Subject line (primary, untruncated) and the filename stem
364+
(fallback). The generic \"inline.patch\" name carries no signal and
365+
is skipped, but an inline patch's parsed Subject still counts. Empty
366+
when the report has no distinctive patch."
367+
[report-pull]
368+
(into #{}
369+
(comp
370+
(mapcat (fn [p]
371+
(cond-> [(patch-canon (some-> (:patch/subject p)
372+
(str/replace #"(?i)^\s*\[patch[^]]*\]\s*" "")))]
373+
(not= "inline.patch" (:patch/filename p))
374+
(conj (patch-canon (:patch/filename p))))))
375+
(keep identity))
376+
(:report/patches report-pull)))
377+
378+
(defn- patch-idents-conflict?
379+
"True when both reports carry patch identifiers and none matches across
380+
the two (exact, or one a >=10-char prefix of the other to tolerate
381+
git's filename truncation): the attachments are clearly different
382+
patches, so a subject-keyed auto-supersession must be vetoed. Empty on
383+
either side => no signal => never a conflict (patch content vetoes, it
384+
is never a requirement)."
385+
[idents-a idents-b]
386+
(boolean
387+
(and (seq idents-a) (seq idents-b)
388+
(not (some (fn [a]
389+
(some (fn [b]
390+
(or (= a b)
391+
(let [m (min (count a) (count b))]
392+
(and (>= m 10)
393+
(= (subs a 0 m) (subs b 0 m))))))
394+
idents-b))
395+
idents-a)))))
396+
397+
(defn- supersede-candidate-eids
398+
"Auto-supersession targets for a new patch: thread-adjacent reports
399+
(`nearest-report-eids`) plus open same-sender patches sharing a common
400+
thread ancestor with `email`. Computed once and shared by both hooks
401+
(each re-checks :report/closed on its own snapshot, so the first hook's
402+
closures are seen by the second)."
403+
[db report-eid email nearest-report-eids]
404+
(let [sender-wide (open-patch-eids-by-sender
405+
db report-eid (:email/author-address email))
406+
email-closure (delay (ancestor-mid-closure db email))]
407+
(into (set nearest-report-eids)
408+
(filter #(shares-common-ancestor? db @email-closure %))
409+
sender-wide)))
410+
411+
(defn- close-patch-previous-version! [conn report-eid report-info email candidate-eids]
276412
(let [new-version (:version report-info)
277-
new-topic (:topic report-info)
278413
n (parse-version-number new-version)]
279414
(when (and n (>= n 1))
280415
(let [versions-to-close (cond-> #{new-version}
281416
(> n 1) (conj (str "v" (dec n))))
282-
new-topic-lc (some-> new-topic str/lower-case)
283-
;; Single snapshot is safe: the daemon is single-threaded
284-
;; on this section. Restore per-iteration refresh if that
285-
;; invariant breaks.
417+
;; Match on the full normalized subject, not the colon topic:
418+
;; the topic prefix (\"ox-texinfo\") is shared by every patch in
419+
;; a numbered series, so it would conflate distinct siblings
420+
;; (\"[PATCH v5 2/4] ox-texinfo: @itemx\" vs \"[PATCH v5 3/4]
421+
;; ox-texinfo: Define ...\"). The normalized subject drops the
422+
;; bracket tag, keeping each member's own title.
423+
new-subj (normalize-subject (:email/subject email))
424+
;; One snapshot is safe: this section is single-threaded.
286425
db (d/db conn)
426+
new-idents (patch-idents
427+
(d/pull db [{:report/patches patch-pull}] report-eid))
287428
candidates (keep
288429
(fn [rid]
289430
(let [r (d/pull db
290431
[:report/type :report/version
291432
:report/topic-value :report/closed
292-
:report/message-id]
433+
:report/message-id
434+
{:report/email [:email/subject]}
435+
{:report/patches patch-pull}]
293436
rid)]
294437
(when (and (= :patch (:report/type r))
295438
(contains? versions-to-close
296439
(:report/version r))
297440
(not (:report/closed r))
298-
(= new-topic-lc
299-
(some-> (:report/topic-value r)
300-
str/lower-case)))
441+
new-subj
442+
(= new-subj
443+
(normalize-subject
444+
(get-in r [:report/email :email/subject])))
445+
;; ... but veto plainly different patches.
446+
(not (patch-idents-conflict?
447+
new-idents (patch-idents r))))
301448
[rid r])))
302-
nearest-report-eids)]
449+
candidate-eids)]
303450
(doseq [[rid r] candidates]
304451
(auto-supersede-patch!
305452
conn rid report-eid email
@@ -308,30 +455,27 @@
308455
"(" (:report/message-id r) ") "
309456
"(superseded by " new-version ")")))))))
310457

311-
(defn- normalize-subject
312-
"Strip Re:/Fwd: prefixes and bracketed tags to get the base subject."
313-
[subject]
314-
(when subject
315-
(-> subject
316-
(str/replace #"(?i)^(\s*(Re|Fwd)\s*:\s*)+" "")
317-
(str/replace #"\[[^\]]*\]\s*" "")
318-
str/trim
319-
str/lower-case)))
320-
321458
(defn- close-superseded-thread-patches!
322-
"Close open ancestor patch reports sharing the new patch's base
323-
subject (Re:/[TAG] stripped). Handles unnumbered re-sends."
324-
[conn report-eid email nearest-report-eids]
325-
(let [new-subj (normalize-subject (:email/subject email))
326-
db (d/db conn)]
327-
(when (and new-subj (seq nearest-report-eids))
328-
(doseq [rid nearest-report-eids
459+
"Close open patch reports (thread-adjacent, or from the same sender
460+
and tracing back to a common ancestor -- see `supersede-candidate-eids`)
461+
sharing the new patch's base subject (Re:/[TAG] stripped). Handles
462+
unnumbered re-sends, including ones posted on a different thread branch."
463+
[conn report-eid email candidate-eids]
464+
(let [new-subj (normalize-subject (:email/subject email))
465+
db (d/db conn)
466+
new-idents (patch-idents
467+
(d/pull db [{:report/patches patch-pull}] report-eid))]
468+
(when (and new-subj (seq candidate-eids))
469+
(doseq [rid candidate-eids
329470
:when (not= rid report-eid)]
330471
(let [r (d/pull db [:report/type :report/closed :report/message-id
331-
{:report/email [:email/subject]}] rid)]
472+
{:report/email [:email/subject]}
473+
{:report/patches patch-pull}] rid)]
332474
(when (and (= :patch (:report/type r))
333475
(not (:report/closed r))
334-
(= new-subj (normalize-subject (get-in r [:report/email :email/subject]))))
476+
(= new-subj (normalize-subject (get-in r [:report/email :email/subject])))
477+
;; ... but veto plainly different patches.
478+
(not (patch-idents-conflict? new-idents (patch-idents r))))
335479
(auto-supersede-patch!
336480
conn rid report-eid email
337481
(str (:report/message-id r) " (superseded by same-subject thread patch)"))))))))
@@ -647,10 +791,13 @@
647791
(link-rel! conn report-eid (:type report-info) email parent-eids))
648792
(when (:close-changes plan)
649793
(close-changes-for-release! conn (:version report-info) email report-eid))
650-
(when (:close-previous-version plan)
651-
(close-patch-previous-version! conn report-eid report-info email nearest-eids))
652-
(when (:close-superseded-thread plan)
653-
(close-superseded-thread-patches! conn report-eid email nearest-eids))
794+
;; Both auto-supersession hooks share one candidate set; compute it once.
795+
(when (or (:close-previous-version plan) (:close-superseded-thread plan))
796+
(let [cand-eids (supersede-candidate-eids (d/db conn) report-eid email nearest-eids)]
797+
(when (:close-previous-version plan)
798+
(close-patch-previous-version! conn report-eid report-info email cand-eids))
799+
(when (:close-superseded-thread plan)
800+
(close-superseded-thread-patches! conn report-eid email cand-eids))))
654801
(when (:manage-series plan)
655802
(series/manage-series! conn report-eid email report-info from-addr parent-eids))
656803
(when (:store-patches plan)

0 commit comments

Comments
 (0)