Skip to content

Commit 9ed4907

Browse files
committed
feat: Carrier-only dispatch for "Supersedes:" and "Related-to:"
1 parent be21096 commit 9ed4907

5 files changed

Lines changed: 218 additions & 103 deletions

File tree

docs/bark-manual.org

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ report (e.g. =[BUG] foo=) annotates that report; any reply hands
153153
its commands to the nearest report in the thread, even when the
154154
reply itself creates a new report (e.g. a =Re: [PATCH]= v2).
155155

156+
Two relation commands -- =Supersedes:= and =Related-to:= -- name
157+
an explicit external message-id, so their intent is unambiguous.
158+
When a reply *also* creates a new report (e.g. a fresh =[BUG]= filed
159+
as =Re:= of an existing discussion), these two commands apply to
160+
the *new* report rather than the thread parent. Every other command
161+
in the same body still flows to the thread parent.
162+
156163
See the [[#all-commands][complete list of commands]] for the full registry.
157164

158165
** Retracting your own updates
@@ -680,12 +687,12 @@ timeline logic.
680687
** All commands
681688

682689
A _command_ is a keyword sent in a public reply, by a user or a
683-
maintainer. In the "Permission" column, "setter or maintainer"
684-
denotes the user who previously set the attribute; a maintainer
685-
retains a full override in every case.
690+
maintainer. In the "Permission" column, "setter or maintainer" denotes
691+
the user who previously set the attribute; a maintainer retains a full
692+
override in every case.
686693

687-
Triggers act on existing reports only. Annotations also apply to
688-
the carrying mail when that mail itself becomes a new report.
694+
Triggers act on existing reports only. Annotations also apply to the
695+
carrying mail when that mail itself becomes a new report.
689696

690697
*** Triggers
691698

src/bark/commands.clj

Lines changed: 95 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,71 +1154,109 @@
11541154
(:report/close-reason word-result) (assoc :report/close-reason
11551155
(:report/close-reason word-result)))))))
11561156

1157+
(def carrier-eligible-ids
1158+
"Command ids whose intent is unambiguously cross-report: when carried
1159+
by a mail that also creates a new report, they apply to that new
1160+
report rather than the thread parent. Restricted to two relation
1161+
annotations with explicit external mids:
1162+
- :supersedes -- 'this new report supersedes <old>'
1163+
- :related-to -- 'this new report is related to <other>'
1164+
1165+
Excluded by design:
1166+
- :superseded-by / :duplicate-of (no one opens a new report just to
1167+
declare it's superseded or a duplicate);
1168+
- state-change triggers (close/ack/own) -- ambiguous on a brand-new
1169+
report;
1170+
- non-relation annotations (urgent/important/topic/deadline/expiry)
1171+
-- the intent is usually 'this thread', not the new report.
1172+
1173+
Unsets (:unsupersedes, :unrelated-to) are intentionally NOT in the
1174+
carrier set: there is nothing to undo on a freshly-created report."
1175+
#{:supersedes :related-to})
1176+
11571177
(defn apply-commands!
11581178
"Apply commands from `email` against `report-eid`.
11591179
A reply shipping patch content also fires an implicit `Acked. Owned.`,
11601180
gated by :patch-triggers? and report-type ∈ #{:bug :request}.
1161-
Returns true if anything was applied."
1162-
[conn report-eid report-type email source-map roles delivery]
1163-
(let [body-text (common/email-body-text email)
1164-
db (d/db conn)
1165-
from-addr (:email/author-address email)
1166-
eid (:db/id email)
1167-
report-mid (:report/message-id (d/entity db report-eid))
1168-
src-name (d/q '[:find ?src . :in $ ?rid
1169-
:where [?rid :report/email ?e] [?e :email/source ?src]] db report-eid)
1170-
source-cfg (when-let [cfg (get source-map src-name)]
1171-
(periods/source-cfg-at-date cfg (:email/date-sent email)))
1172-
src-cmds (build-source-commands source-cfg)
1173-
overrides (:overrides src-cmds)
1174-
is-maint? (common/maintainer? roles from-addr (:email/date-sent email))
1175-
fail-ctx (when (and from-addr src-name)
1176-
{:source src-name
1177-
:from-addr from-addr
1178-
:email-date (:email/date-sent email)
1179-
:report-mid report-mid})
1180-
body-words (when body-text
1181-
(detect-words report-type body-text src-cmds))
1182-
;; A reply shipping patch content credits its author on the parent
1183-
;; bug/request -- NOT on a parent patch report (a v2 reply would
1184-
;; otherwise spuriously mark v1 as owned by v2's author; super-
1185-
;; session handles patch->patch ownership transfer separately).
1186-
;; The reply-guard prevents a root [BUG]+.patch from self-crediting.
1187-
implicit (when (and (contains? #{:bug :request} report-type)
1188-
(common/patch-triggers? source-cfg)
1189-
(:email/in-reply-to email)
1190-
(detect/has-patch-content? email))
1191-
{:report/acked true :report/owned true})
1192-
;; Acked is a second-party validation: the reporter cannot ack
1193-
;; their own report. Owned, by contrast, may stay with the
1194-
;; reporter ("I filed this and I'll fix it").
1195-
reporter (some-> (d/pull db [{:report/email [:email/author-address]}]
1196-
report-eid)
1197-
:report/email :email/author-address str/lower-case)
1198-
self-ack? (and from-addr reporter (= (str/lower-case from-addr) reporter))
1199-
word-result (cond-> (merge implicit body-words)
1200-
self-ack? (dissoc :report/acked)
1201-
:always (filter-words-by-scope overrides is-maint? fail-ctx))
1202-
lines (when body-text
1203-
(->> (detect-lines report-type body-text overrides
1204-
(:email/date-sent email)
1205-
(:line-patterns src-cmds))
1206-
(remove (fn [d]
1207-
(and (= :set (:action d))
1208-
(= :report/acked (:attr d))
1209-
reporter
1210-
(= (some-> (:email-address d) str/lower-case)
1211-
reporter))))
1212-
vec))
1213-
closed? (some? (:report/closed (d/pull db [:report/closed] report-eid)))]
1181+
Returns true if anything was applied.
1182+
1183+
`line-filter` is one of:
1184+
nil -- process every command, no filtering.
1185+
:carrier-only -- process ONLY lines whose id is in
1186+
`carrier-eligible-ids`; skip words, votes,
1187+
and implicit ack/own. Used when this email
1188+
is a reply that also creates a new report:
1189+
Supersedes:/Related-to: in its body apply to
1190+
the new report unambiguously, the rest goes
1191+
to the thread parent via a separate call.
1192+
:no-carrier -- process everything EXCEPT carrier-eligible
1193+
lines. Used for the thread-parent call in the
1194+
same scenario, so carrier lines are not
1195+
double-applied."
1196+
[conn report-eid report-type email source-map roles delivery line-filter]
1197+
(let [carrier-only? (= :carrier-only line-filter)
1198+
no-carrier? (= :no-carrier line-filter)
1199+
body-text (common/email-body-text email)
1200+
db (d/db conn)
1201+
from-addr (:email/author-address email)
1202+
eid (:db/id email)
1203+
report-mid (:report/message-id (d/entity db report-eid))
1204+
src-name (d/q '[:find ?src . :in $ ?rid
1205+
:where [?rid :report/email ?e] [?e :email/source ?src]] db report-eid)
1206+
source-cfg (when-let [cfg (get source-map src-name)]
1207+
(periods/source-cfg-at-date cfg (:email/date-sent email)))
1208+
src-cmds (build-source-commands source-cfg)
1209+
overrides (:overrides src-cmds)
1210+
is-maint? (common/maintainer? roles from-addr (:email/date-sent email))
1211+
fail-ctx (when (and from-addr src-name)
1212+
{:source src-name
1213+
:from-addr from-addr
1214+
:email-date (:email/date-sent email)
1215+
:report-mid report-mid})
1216+
;; In :carrier-only mode we don't credit anyone on the brand-
1217+
;; new report -- words, votes, implicit ack/own all go to the
1218+
;; thread parent through the sibling :no-carrier call.
1219+
body-words (when (and body-text (not carrier-only?))
1220+
(detect-words report-type body-text src-cmds))
1221+
implicit (when (and (not carrier-only?)
1222+
(contains? #{:bug :request} report-type)
1223+
(common/patch-triggers? source-cfg)
1224+
(:email/in-reply-to email)
1225+
(detect/has-patch-content? email))
1226+
{:report/acked true :report/owned true})
1227+
reporter (some-> (d/pull db [{:report/email [:email/author-address]}]
1228+
report-eid)
1229+
:report/email :email/author-address str/lower-case)
1230+
self-ack? (and from-addr reporter (= (str/lower-case from-addr) reporter))
1231+
word-result (cond-> (merge implicit body-words)
1232+
self-ack? (dissoc :report/acked)
1233+
:always (filter-words-by-scope overrides is-maint? fail-ctx))
1234+
line-id-ok? (cond
1235+
carrier-only? carrier-eligible-ids
1236+
no-carrier? (complement carrier-eligible-ids)
1237+
:else (constantly true))
1238+
lines (when body-text
1239+
(->> (detect-lines report-type body-text overrides
1240+
(:email/date-sent email)
1241+
(:line-patterns src-cmds))
1242+
(filter #(line-id-ok? (:id %)))
1243+
(remove (fn [d]
1244+
(and (= :set (:action d))
1245+
(= :report/acked (:attr d))
1246+
reporter
1247+
(= (some-> (:email-address d) str/lower-case)
1248+
reporter))))
1249+
vec))
1250+
closed? (some? (:report/closed (d/pull db [:report/closed] report-eid)))]
12141251
(if closed?
12151252
(do (when (seq lines)
12161253
(try-unclosed! conn report-eid lines eid is-maint? from-addr fail-ctx))
12171254
(boolean (seq lines)))
1218-
(let [voted? (when-let [vote (and (= :request report-type) from-addr body-text
1219-
(detect-vote body-text))]
1220-
(apply-vote! conn report-eid from-addr vote email delivery source-cfg)
1221-
true)]
1255+
(let [voted? (when (and (not carrier-only?) body-text)
1256+
(when-let [vote (and (= :request report-type) from-addr
1257+
(detect-vote body-text))]
1258+
(apply-vote! conn report-eid from-addr vote email delivery source-cfg)
1259+
true))]
12221260
(apply-words! conn report-eid word-result eid (:email/message-id email) from-addr
12231261
source-cfg)
12241262
(apply-lines! conn report-eid lines eid from-addr is-maint? fail-ctx)

src/bark/digest.clj

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -512,8 +512,10 @@
512512
(defn- apply-commands-on-nearest!
513513
"Apply commands to the nearest reports of a reply, refreshing roles
514514
per source when reports come from different sources. Ensures the
515-
author is recorded as a participant if any command matched."
516-
[conn email from-addr source-name rroles source-map delivery nearest-eids]
515+
author is recorded as a participant if any command matched.
516+
517+
`line-filter` is forwarded to `apply-commands!`; see its docstring."
518+
[conn email from-addr source-name rroles source-map delivery nearest-eids line-filter]
517519
(let [rid-info (reduce (fn [m [r t s]] (assoc m r [t s]))
518520
{}
519521
(d/q '[:find ?r ?t ?src
@@ -526,7 +528,8 @@
526528
any-cmd? (reduce (fn [acc rid]
527529
(if-let [[rtype rsrc] (get rid-info rid)]
528530
(let [rroles (if rsrc (roles/get-tenures (d/db conn) rsrc) rroles)]
529-
(if (commands/apply-commands! conn rid rtype email source-map rroles delivery)
531+
(if (commands/apply-commands! conn rid rtype email source-map
532+
rroles delivery line-filter)
530533
true acc))
531534
acc))
532535
false nearest-eids)]
@@ -638,20 +641,37 @@
638641
(when (seq parent-eids)
639642
(attach-as-descendant! conn eid email from-addr parent-eids))
640643

641-
;; A command targets the thread, never the mail
642-
;; that carries it -- unless that mail opens its
643-
;; thread. Concretely: a root mail (no
644-
;; In-Reply-To) that introduces a report carries
645-
;; its commands onto that report; any reply hands
646-
;; them to the nearest report in the thread.
647-
(cond
648-
(and report-eid (nil? (:email/in-reply-to email)))
649-
(commands/apply-commands! conn report-eid (:type report-info)
650-
email source-map rroles delivery)
651-
652-
(seq nearest-eids)
653-
(apply-commands-on-nearest! conn email from-addr source-name rroles
654-
source-map delivery nearest-eids)))
644+
;; Command dispatch:
645+
;; - Root mail opening a report (no In-Reply-To):
646+
;; carry every command onto that report.
647+
;; - Reply with no new report:
648+
;; hand every command to the nearest thread report.
649+
;; - Reply that itself creates a new report (e.g. a
650+
;; fresh `[BUG] ...` filed as `Re:` of a discussion):
651+
;; split the body in two -- the carrier-eligible
652+
;; relation lines (Supersedes:, Related-to:) name
653+
;; external mids and unambiguously apply to the
654+
;; *new* report; everything else (triggers, other
655+
;; annotations, words, votes, implicit ack/own)
656+
;; still flows to the thread parent.
657+
(let [is-reply? (some? (:email/in-reply-to email))
658+
creates-report? (some? report-eid)]
659+
(cond
660+
(and creates-report? (not is-reply?))
661+
(commands/apply-commands! conn report-eid (:type report-info)
662+
email source-map rroles delivery nil)
663+
664+
(and creates-report? is-reply? (seq nearest-eids))
665+
(do (commands/apply-commands! conn report-eid (:type report-info)
666+
email source-map rroles delivery
667+
:carrier-only)
668+
(apply-commands-on-nearest! conn email from-addr source-name rroles
669+
source-map delivery nearest-eids
670+
:no-carrier))
671+
672+
(seq nearest-eids)
673+
(apply-commands-on-nearest! conn email from-addr source-name rroles
674+
source-map delivery nearest-eids nil))))
655675

656676
;; Phase 4: post-creation hooks (plan is pure, execution is effectful)
657677
(when report-eid

test/bark/digest_test.clj

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,6 +1244,56 @@
12441244
(finally
12451245
(teardown! ctx))))))
12461246

1247+
(deftest carrier-dispatch-supersedes-applies-to-new-report
1248+
(testing "A reply that creates a new report AND carries Supersedes:
1249+
<external-mid> applies the relation to the new report
1250+
(closes the external target), not to the thread parent."
1251+
(let [{:keys [conn] :as ctx} (setup-db!)]
1252+
(try
1253+
;; 1. Old bug (will be superseded).
1254+
(store-and-process! conn
1255+
(mk-email {:mid "<old-bug@test.org>"
1256+
:subject "[BUG] narrow framing"
1257+
:from "alice@test.org"
1258+
:date #inst "2026-05-01T10:00:00"
1259+
:body "narrow report\n"})
1260+
"direct")
1261+
;; 2. Unrelated parent thread (will be the In-Reply-To target).
1262+
(store-and-process! conn
1263+
(mk-email {:mid "<thread-root@test.org>"
1264+
:subject "[BUG] something else"
1265+
:from "bob@test.org"
1266+
:date #inst "2026-05-01T11:00:00"
1267+
:body "another bug\n"})
1268+
"direct")
1269+
;; 3. A reply that itself opens a new [BUG] and supersedes the
1270+
;; old bug from step 1. Before the carrier loosening the
1271+
;; Supersedes: would have been dispatched to <thread-root>
1272+
;; (the nearest report in the thread), where it would have
1273+
;; no useful meaning. With the loosening it lands on the
1274+
;; new report and closes <old-bug>.
1275+
(store-and-process! conn
1276+
(mk-email {:mid "<new-bug@test.org>"
1277+
:subject "[BUG] broader framing"
1278+
:from "carol@test.org"
1279+
:date #inst "2026-05-01T12:00:00"
1280+
:in-reply-to "<thread-root@test.org>"
1281+
:body "Supersedes: <old-bug@test.org>\n"})
1282+
"direct")
1283+
(let [db (d/db conn)
1284+
old (get-report db "<old-bug@test.org>")
1285+
new (get-report db "<new-bug@test.org>")
1286+
root (get-report db "<thread-root@test.org>")]
1287+
(is (some? new) "new report was created")
1288+
(is (= :bug (:report/type new)))
1289+
(is (some? (:report/closed old))
1290+
"old bug was closed via Supersedes: from the new report's body")
1291+
(is (= :superseded (:report/close-reason old)))
1292+
(is (nil? (:report/closed root))
1293+
"the thread parent (unrelated to the supersede) is untouched"))
1294+
(finally
1295+
(teardown! ctx))))))
1296+
12471297
(deftest pending-thread-references-anchor
12481298
(testing "Reply with missing IRT but a known References ancestor is threaded immediately."
12491299
(let [{:keys [conn] :as ctx} (setup-db!)]

0 commit comments

Comments
 (0)