Skip to content

Commit 64229e1

Browse files
committed
fix: Let close commands reach patches orphaned by auto-supersession
1 parent f1f6422 commit 64229e1

3 files changed

Lines changed: 208 additions & 8 deletions

File tree

src/bone/digest.clj

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,13 +732,53 @@
732732
(commands/apply-commands! conn patch-rid ptype email source-map
733733
proles delivery :no-cross-refs))))))
734734

735+
(defn- supersession-heads
736+
"Open successors of a report closed as :superseded, following the
737+
active :supersedes chain forward (:rel/from = old, :rel/to = new).
738+
Empty when `rid` is open, closed for another reason, or every
739+
successor is closed. Cycle-safe. Series restarts close reports
740+
without posing :supersedes relations, so those closures are not
741+
traversable here."
742+
[db rid]
743+
(loop [stack [rid], seen #{}, heads []]
744+
(if-let [r (peek stack)]
745+
(let [stack' (pop stack)]
746+
(if (seen r)
747+
(recur stack' seen heads)
748+
(let [{closed :report/closed reason :report/close-reason}
749+
(d/pull db [:report/closed :report/close-reason] r)]
750+
(cond
751+
(and closed (= :superseded reason))
752+
(recur (into stack'
753+
(d/q '[:find [?to ...]
754+
:in $ ?from
755+
:where
756+
[?rel :rel/kind :supersedes]
757+
[?rel :rel/from ?from]
758+
[?rel :rel/active? true]
759+
[?rel :rel/to ?to]]
760+
db r))
761+
(conj seen r) heads)
762+
(and (nil? closed) (not= r rid))
763+
(recur stack' (conj seen r) (conj heads r))
764+
:else (recur stack' (conj seen r) heads)))))
765+
heads)))
766+
735767
(defn- apply-commands-on-nearest!
736768
"Apply commands to the nearest reports of a reply (roles refreshed
737769
per source), record the author as participant on any match, and
738770
broadcast cover-letter commands to their series. `line-filter` is
739-
forwarded to `apply-commands!`."
771+
forwarded to `apply-commands!`. Reports auto-superseded since the
772+
reply's branch forked (a revision may live on a sibling branch) are
773+
expanded with the open heads of their supersession chain, so the
774+
commands also reach the live submission."
740775
[conn email from-addr source-name rroles source-map delivery nearest-eids line-filter]
741776
(let [db (d/db conn)
777+
nearest-eids (into (vec nearest-eids)
778+
(comp (mapcat #(supersession-heads db %))
779+
(distinct)
780+
(remove (set nearest-eids)))
781+
nearest-eids)
742782
info (rids->type+source db nearest-eids)
743783
src->roles (roles-by-source db info)
744784
any-cmd? (reduce (fn [acc rid]

src/bone/series.clj

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,18 @@
126126
(defn set-cover-letter! [conn series-eid email-eid]
127127
(d/transact! conn [{:db/id series-eid :series/cover-letter email-eid}]))
128128

129+
(defn- awaiting-cover-series?
130+
"True when `series-eid` has no patches yet and its cover letter is
131+
one of `parent-mids`: the series the current patch is about to join
132+
via its own cover, not an old revision to supersede."
133+
[db series-eid parent-mids]
134+
(let [{patches :series/patches cover :series/cover-letter}
135+
(d/pull db [{:series/patches [:db/id]}
136+
{:series/cover-letter [:email/message-id]}]
137+
series-eid)]
138+
(and (empty? patches)
139+
(contains? parent-mids (:email/message-id cover)))))
140+
129141
(defn manage-series!
130142
"After creating a patch report, manage its series membership."
131143
[conn report-eid email report-info from-addr parent-report-eids]
@@ -145,6 +157,11 @@
145157
[?r :report/patch-seq ?seq]]
146158
db existing-series)]
147159
(some #(str/starts-with? % "1/") existing-seqs)))))
160+
parent-mids (when (and restart? (seq parent-report-eids))
161+
(set (d/q '[:find [?mid ...]
162+
:in $ [?r ...]
163+
:where [?r :report/message-id ?mid]]
164+
db parent-report-eids)))
148165
ancestor? (when restart?
149166
(let [old-mids (into
150167
(set (d/q '[:find [?mid ...]
@@ -156,15 +173,15 @@
156173
:in $ [?s ...]
157174
:where [?s :series/cover-letter ?e]
158175
[?e :email/message-id ?mid]]
159-
db existing-series))
160-
parent-mids (when (seq parent-report-eids)
161-
(set (d/q '[:find [?mid ...]
162-
:in $ [?r ...]
163-
:where [?r :report/message-id ?mid]]
164-
db parent-report-eids)))]
176+
db existing-series))]
165177
(some old-mids parent-mids)))]
166178
(when (and restart? ancestor?)
167-
(doseq [sid existing-series]
179+
;; A numbered patch must not close the empty series its own
180+
;; cover letter just opened: that series is the one it is
181+
;; joining, not an old revision to supersede.
182+
(doseq [sid existing-series
183+
:when (not (and (pos? n)
184+
(awaiting-cover-series? db sid parent-mids)))]
168185
(close-series! conn sid email-eid)
169186
(when (:version report-info)
170187
(supersede-series-reports! conn sid email-eid))

test/bone/digest_test.clj

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,6 +1670,83 @@
16701670
(finally
16711671
(teardown! ctx))))))
16721672

1673+
(deftest series-restart-keeps-own-cover-series
1674+
(testing "A re-roll posted with a fresh cover letter supersedes the
1675+
old series WITHOUT closing the series its own cover just
1676+
created: the numbered patches join the cover's series, and
1677+
a close command on the cover reaches them."
1678+
(let [{:keys [conn] :as ctx} (setup-db!)]
1679+
(try
1680+
;; v1 series without a cover: 1/3 root, 2/3 reply (aborted).
1681+
(store-and-process! conn
1682+
(mk-email {:mid "<sr-v1-1@test.org>"
1683+
:subject "[PATCH t 1/3] step a"
1684+
:from "user@test.org"
1685+
:date #inst "2026-06-10T10:00:00"
1686+
:body (str "step a\n\n"
1687+
"diff --git a/z.clj b/z.clj\n"
1688+
"--- a/z.clj\n+++ b/z.clj\n"
1689+
"@@ -1,1 +1,1 @@\n-a\n+b\n")})
1690+
"direct")
1691+
(store-and-process! conn
1692+
(mk-email {:mid "<sr-v1-2@test.org>"
1693+
:subject "[PATCH t 2/3] step b"
1694+
:from "user@test.org"
1695+
:date #inst "2026-06-10T10:01:00"
1696+
:in-reply-to "<sr-v1-1@test.org>"
1697+
:body (str "step b\n\n"
1698+
"diff --git a/z.clj b/z.clj\n"
1699+
"--- a/z.clj\n+++ b/z.clj\n"
1700+
"@@ -1,1 +1,1 @@\n-b\n+c\n")})
1701+
"direct")
1702+
;; v2 re-roll: new thread with a cover, patches reply to it.
1703+
(store-and-process! conn
1704+
(mk-email {:mid "<sr-cov@test.org>"
1705+
:subject "[PATCH t v2 0/2] revamp"
1706+
:from "user@test.org"
1707+
:date #inst "2026-06-11T10:00:00"
1708+
:body "Series v2\n"})
1709+
"direct")
1710+
(doseq [i [1 2]]
1711+
(store-and-process! conn
1712+
(mk-email {:mid (str "<sr-v2-" i "@test.org>")
1713+
:subject (str "[PATCH t v2 " i "/2] step " i)
1714+
:from "user@test.org"
1715+
:date #inst "2026-06-11T10:01:00"
1716+
:in-reply-to "<sr-cov@test.org>"
1717+
:body (str "step " i "\n\n"
1718+
"diff --git a/z.clj b/z.clj\n"
1719+
"--- a/z.clj\n+++ b/z.clj\n"
1720+
"@@ -1,1 +1,1 @@\n-a\n+b\n")})
1721+
"direct"))
1722+
(let [db (d/db conn)
1723+
v2 (get-series-by-id db "t|user@test.org|2")]
1724+
(testing "old v1 series is closed by the restart"
1725+
(is (some? (:series/closed (get-series-by-id db "t|user@test.org|3")))))
1726+
(testing "v2 patches join their own cover's series, still open"
1727+
(is (nil? (:series/closed v2)))
1728+
(is (= "<sr-cov@test.org>"
1729+
(get-in v2 [:series/cover-letter :email/message-id])))
1730+
(is (= 2 (series-patch-count db "t|user@test.org|2")))
1731+
(is (nil? (get-series-by-id db "t|user@test.org|2#2"))
1732+
"no orphan series is created for the numbered patches")))
1733+
;; Applied. on the cover must now reach both patches.
1734+
(store-and-process! conn
1735+
(mk-email {:mid "<sr-applied@test.org>"
1736+
:subject "Re: [PATCH t v2 0/2] revamp"
1737+
:from "admin@test.org"
1738+
:date #inst "2026-06-12T09:00:00"
1739+
:in-reply-to "<sr-cov@test.org>"
1740+
:body "Applied. Thanks!\n"})
1741+
"direct")
1742+
(let [db (d/db conn)]
1743+
(is (some? (:report/closed (get-report db "<sr-v2-1@test.org>")))
1744+
"patch 1/2 closed via cover broadcast")
1745+
(is (some? (:report/closed (get-report db "<sr-v2-2@test.org>")))
1746+
"patch 2/2 closed via cover broadcast"))
1747+
(finally
1748+
(teardown! ctx))))))
1749+
16731750
(deftest cover-letter-broadcast-supersede
16741751
(testing "Superseded-by: on a cover letter supersedes every patch:
16751752
each report closes with reason :superseded and points to
@@ -1791,6 +1868,72 @@
17911868
(finally
17921869
(teardown! ctx))))))
17931870

1871+
(deftest close-command-follows-supersession-chain
1872+
(testing "Applied. in reply to a superseded revision's branch closes
1873+
the live head of the supersession chain, even when that head
1874+
lives on a sibling thread branch never mentioned by the reply."
1875+
(let [{:keys [conn] :as ctx} (setup-db!)]
1876+
(try
1877+
(store-and-process! conn
1878+
(mk-email {:mid "<sc-v1@test.org>"
1879+
:subject "[PATCH] table: speed up align"
1880+
:from "user@test.org"
1881+
:date #inst "2026-06-20T10:00:00"
1882+
:body "Initial patch.\n"})
1883+
"direct")
1884+
;; v2 supersedes v1.
1885+
(store-and-process! conn
1886+
(assoc (mk-email {:mid "<sc-v2@test.org>"
1887+
:subject "Re: [PATCH v2] table: speed up align"
1888+
:from "user@test.org"
1889+
:date #inst "2026-06-21T10:00:00"
1890+
:in-reply-to "<sc-v1@test.org>"
1891+
:body "Updated patch.\n"})
1892+
:email/attachments [{:attachment/filename "0002-align.patch"}])
1893+
"direct")
1894+
;; Review comment under v2: the branch the closer will reply to.
1895+
(store-and-process! conn
1896+
(mk-email {:mid "<sc-review@test.org>"
1897+
:subject "Re: [PATCH v2] table: speed up align"
1898+
:from "reviewer@test.org"
1899+
:date #inst "2026-06-22T10:00:00"
1900+
:in-reply-to "<sc-v2@test.org>"
1901+
:body "Looks good to me.\n"})
1902+
"direct")
1903+
;; v3 posted on its own branch supersedes v2 (sibling-branch
1904+
;; auto-supersession); the closer's branch never mentions it.
1905+
(store-and-process! conn
1906+
(assoc (mk-email {:mid "<sc-v3@test.org>"
1907+
:subject "Re: [PATCH v3] table: speed up align"
1908+
:from "user@test.org"
1909+
:date #inst "2026-06-23T10:00:00"
1910+
:in-reply-to "<sc-v1@test.org>"
1911+
:body "Third version.\n"})
1912+
:email/attachments [{:attachment/filename "0003-align.patch"}])
1913+
"direct")
1914+
;; The maintainer replies on the review branch: nearest report
1915+
;; ancestor is v2, closed :superseded by then.
1916+
(store-and-process! conn
1917+
(mk-email {:mid "<sc-applied@test.org>"
1918+
:subject "Re: [PATCH v2] table: speed up align"
1919+
:from "admin@test.org"
1920+
:date #inst "2026-06-24T10:00:00"
1921+
:in-reply-to "<sc-review@test.org>"
1922+
:body "Applied, onto main.\n"})
1923+
"direct")
1924+
(let [db (d/db conn)
1925+
v1 (get-report db "<sc-v1@test.org>")
1926+
v2 (get-report db "<sc-v2@test.org>")
1927+
v3 (get-report db "<sc-v3@test.org>")]
1928+
(is (= :superseded (:report/close-reason v1)) "v1 stays superseded")
1929+
(is (= :superseded (:report/close-reason v2)) "v2 stays superseded")
1930+
(is (some? (:report/closed v3))
1931+
"the open head v3 is closed by the forwarded command")
1932+
(is (= :resolved (:report/close-reason v3)))
1933+
(is (= "admin@test.org" (:report/closed-address v3))))
1934+
(finally
1935+
(teardown! ctx))))))
1936+
17941937
(deftest supersede-does-not-cross-unrelated-threads
17951938
(testing "Two patches from the same sender with the same (generic,
17961939
recycled) subject, but that never share any common

0 commit comments

Comments
 (0)