|
256 | 256 | (defn- parse-version-number [v] |
257 | 257 | (when v (when-let [[_ n] (re-find #"^v(\d+)$" v)] (parse-long n)))) |
258 | 258 |
|
| 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 | + |
259 | 319 | (defn- auto-supersede-patch! |
260 | 320 | "Close `old-rid` as :superseded by `new-report-eid`, pose the |
261 | 321 | :supersedes + :related-to relations, propagate auto-credit transfers." |
|
272 | 332 | (tracking/bump-report-updated! conn old-rid) |
273 | 333 | (log/info "Auto-closed patch" log-msg))) |
274 | 334 |
|
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] |
276 | 412 | (let [new-version (:version report-info) |
277 | | - new-topic (:topic report-info) |
278 | 413 | n (parse-version-number new-version)] |
279 | 414 | (when (and n (>= n 1)) |
280 | 415 | (let [versions-to-close (cond-> #{new-version} |
281 | 416 | (> 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. |
286 | 425 | db (d/db conn) |
| 426 | + new-idents (patch-idents |
| 427 | + (d/pull db [{:report/patches patch-pull}] report-eid)) |
287 | 428 | candidates (keep |
288 | 429 | (fn [rid] |
289 | 430 | (let [r (d/pull db |
290 | 431 | [:report/type :report/version |
291 | 432 | :report/topic-value :report/closed |
292 | | - :report/message-id] |
| 433 | + :report/message-id |
| 434 | + {:report/email [:email/subject]} |
| 435 | + {:report/patches patch-pull}] |
293 | 436 | rid)] |
294 | 437 | (when (and (= :patch (:report/type r)) |
295 | 438 | (contains? versions-to-close |
296 | 439 | (:report/version r)) |
297 | 440 | (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)))) |
301 | 448 | [rid r]))) |
302 | | - nearest-report-eids)] |
| 449 | + candidate-eids)] |
303 | 450 | (doseq [[rid r] candidates] |
304 | 451 | (auto-supersede-patch! |
305 | 452 | conn rid report-eid email |
|
308 | 455 | "(" (:report/message-id r) ") " |
309 | 456 | "(superseded by " new-version ")"))))))) |
310 | 457 |
|
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 | | - |
321 | 458 | (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 |
329 | 470 | :when (not= rid report-eid)] |
330 | 471 | (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)] |
332 | 474 | (when (and (= :patch (:report/type r)) |
333 | 475 | (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)))) |
335 | 479 | (auto-supersede-patch! |
336 | 480 | conn rid report-eid email |
337 | 481 | (str (:report/message-id r) " (superseded by same-subject thread patch)")))))))) |
|
647 | 791 | (link-rel! conn report-eid (:type report-info) email parent-eids)) |
648 | 792 | (when (:close-changes plan) |
649 | 793 | (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)))) |
654 | 801 | (when (:manage-series plan) |
655 | 802 | (series/manage-series! conn report-eid email report-info from-addr parent-eids)) |
656 | 803 | (when (:store-patches plan) |
|
0 commit comments