Skip to content

Commit 5aa15b3

Browse files
committed
feat!: Collect review trailers, speak kernel review, open commands
Breaking-Users: -by lines and "Not"/"No" retractions no longer require being a maintainer or the original setter.
1 parent 3e99ffc commit 5aa15b3

11 files changed

Lines changed: 692 additions & 260 deletions

File tree

docs/bone-manual.org

Lines changed: 226 additions & 200 deletions
Large diffs are not rendered by default.

resources/bone-schema.edn

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@
124124
:report/last-activity-address {:db/valueType :db.type/string}
125125
:report/has-ics {:db/valueType :db.type/boolean}
126126
:report/has-text-attachments {:db/valueType :db.type/boolean}
127+
;; Git person trailers (Acked-by:, Reviewed-by:, ...) collected from
128+
;; replies to a patch report, verbatim "Key: Name <addr>" strings.
129+
;; Clients applying the patch fold them into the commit message
130+
;; (b4-style). Set semantics: duplicates and replays collapse.
131+
:report/trailers {:db/valueType :db.type/string
132+
:db/cardinality :db.cardinality/many}
127133
:report/updated-at {:db/valueType :db.type/instant}
128134
;; Wall-clock instant the report was first created (ingested), as opposed
129135
;; to the originating email's date (`:report/email` -> :email/date-sent),

resources/emails.edn

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1093,7 +1093,7 @@
10931093
:email/body-text "Owned-by: fixer@test.org\n"}
10941094

10951095
;; =========================================================================
1096-
;; 84 -- Regular user tries Closed-by directive (should be denied)
1096+
;; 84 -- Regular user uses Closed-by directive (open to any user)
10971097
;; =========================================================================
10981098
{
10991099
:email/id "84"

scripts/bone-export.clj

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -449,16 +449,24 @@
449449
[:report/urgent :urgent :urgent-proxy]
450450
[:report/important :important :important-proxy]])
451451

452-
(defn- assoc-owned-name
453-
"Attach :owned-name when the owner's display name is known (looked up
454-
via the global author-names map built once per export)."
455-
[m author-names]
456-
(if-let [owned (:owned m)]
457-
(if-let [nm (get author-names (str/lower-case owned))]
458-
(assoc m :owned-name nm)
452+
(defn- assoc-state-name
453+
"Attach `name-k` when the display name of the address under `addr-k`
454+
is known (looked up via the global author-names map built once per
455+
export)."
456+
[m addr-k name-k author-names]
457+
(if-let [addr (get m addr-k)]
458+
(if-let [nm (get author-names (str/lower-case addr))]
459+
(assoc m name-k nm)
459460
m)
460461
m))
461462

463+
(defn- assoc-state-names
464+
"Attach :owned-name and :acked-name when known."
465+
[m author-names]
466+
(-> m
467+
(assoc-state-name :owned :owned-name author-names)
468+
(assoc-state-name :acked :acked-name author-names)))
469+
462470
(defn- assoc-from-addresses
463471
"Extract addresses from report: direct string attrs and author-address of ref attrs.
464472
Omits -proxy keys when their value equals the corresponding address key."
@@ -717,7 +725,7 @@
717725
:priority (report-priority report)
718726
:replies (report-descendant-count report)}
719727
(assoc-from-addresses report)
720-
(assoc-owned-name (ctx-author-names))
728+
(assoc-state-names (ctx-author-names))
721729
(cond->
722730
from-name (assoc :from-name from-name)
723731
role (assoc :role role)
@@ -726,6 +734,8 @@
726734
(:report/topic-value report) (assoc :topic (:report/topic-value report))
727735
(:report/patch-seq report) (assoc :patch-seq (:report/patch-seq report))
728736
(:report/patch-source report) (assoc :patch-source (mapv name (sort (:report/patch-source report))))
737+
;; Sorted for reproducible exports (cardinality-many = set).
738+
(seq (:report/trailers report)) (assoc :trailers (vec (sort (:report/trailers report))))
729739
arch (assoc :archived-at arch)
730740
(:report/deadline-value report) (assoc :deadline (format-date-iso (:report/deadline-value report)))
731741
(:report/last-activity report) (assoc :last-activity (format-date-iso (:report/last-activity report)))

src/bone/commands.clj

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,13 @@
4242
"(" (str/join "|" (map #(java.util.regex.Pattern/quote %) words))
4343
")(?:" trailing-punct (when-not strict-punct? "|\\s") "|$)")))
4444

45-
(defn- line-pattern [strict-syntax? {:keys [syntax param]}]
46-
(let [qs (java.util.regex.Pattern/quote syntax)
45+
(defn- line-pattern [strict-syntax? {:keys [syntax param]} syntaxes]
46+
(let [qs (if (seq syntaxes)
47+
(str "(?:"
48+
(str/join "|" (map #(java.util.regex.Pattern/quote %)
49+
syntaxes))
50+
")")
51+
(java.util.regex.Pattern/quote syntax))
4752
prefix (common/bang-prefix strict-syntax?)
4853
addr "[^@<>\\s]+@[^@<>\\s]+\\.[^@<>\\s]+" ; email address (dot required)
4954
mid "[^<>\\s]+@[^<>\\s]+" ; bracketed message-id
@@ -69,11 +74,24 @@
6974
words)]))
7075
action-map))))
7176

77+
(defn- command-syntaxes
78+
"Alternative syntaxes of a line command, nil for the single default.
79+
Explicit :syntaxes win; unset commands carrying :not-words accept
80+
the \"Not <word>\" negation of every word in the state's resolved
81+
vocabulary."
82+
[cmd commands-map]
83+
(or (:syntaxes cmd)
84+
(when-let [words (some->> (:not-words cmd) (get commands-map) seq)]
85+
(mapv #(str "Not " (str/lower-case %)) words))))
86+
7287
(def ^:private compile-line-patterns
7388
"Compile colon-line command patterns for a syntax mode."
7489
(memoize
75-
(fn [strict-syntax?]
76-
(mapv (fn [cmd] [cmd (line-pattern strict-syntax? cmd)]) line-commands))))
90+
(fn [strict-syntax? commands-map]
91+
(mapv (fn [cmd]
92+
[cmd (line-pattern strict-syntax? cmd
93+
(command-syntaxes cmd commands-map))])
94+
line-commands))))
7795

7896
(defn build-source-commands
7997
"Return a source-commands descriptor with keys:
@@ -87,7 +105,7 @@
87105
strict-syntax? (= :strict (common/resolve-command-syntax source-cfg))]
88106
{:commands commands
89107
:word-patterns (compile-word-patterns strict-syntax? commands)
90-
:line-patterns (compile-line-patterns strict-syntax?)
108+
:line-patterns (compile-line-patterns strict-syntax? commands)
91109
:strict-syntax? strict-syntax?
92110
:overrides (common/resolve-command-overrides source-cfg)}))
93111

@@ -150,7 +168,8 @@
150168
(assoc :report/close-reason reason))]
151169
(when (seq result) result))))
152170

153-
(def ^:private compiled-lines-loose (compile-line-patterns false))
171+
(def ^:private compiled-lines-loose
172+
(compile-line-patterns false common/default-commands))
154173

155174
(defn detect-lines
156175
"Detect colon-line commands in `body-text`. The optional `compiled`

src/bone/commands/registry.clj

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,23 @@
2424
;; prefixed line (`:syntax` key + optional `:param`).
2525
;;
2626
;; :scope values:
27-
;; :user -- anyone
27+
;; :user -- anyone. The default everywhere: updates
28+
;; are auditable (each records the email
29+
;; that posed it) and reversible, and
30+
;; sources that are not self-moderating
31+
;; channels can tighten commands via the
32+
;; per-command :scope override. Role
33+
;; controls (Add maintainer: ...) are gated
34+
;; separately, in bone.roles.
2835
;; :maintainer -- any maintainer
2936
;; :setter-or-maintainer -- the address that set the attribute, plus
3037
;; any maintainer. Only meaningful for unset
3138
;; commands whose target has a recoverable
3239
;; setter (`setter-ref-attrs` /
3340
;; `setter-scoped-command-ids`).
41+
;; No default entry uses the last two anymore; they remain available to
42+
;; sources that tighten a command via the per-command :scope override
43+
;; (config.edn `:commands {<cmd-id> {:scope ...}}`).
3444
;; ---------------------------------------------------------------------------
3545

3646
(def commands
@@ -41,19 +51,34 @@
4151
{:id :owned :kind :trigger :action :set :attr :report/owned :scope :user
4252
:words :owned :report-types #{:bug :patch :request}}
4353
{:id :closed :kind :trigger :action :set :attr :report/closed :scope :user :words :closed}
44-
;; -by lines (maintainer credits a third party)
45-
{:id :acked-by :kind :trigger :action :set :attr :report/acked :scope :maintainer
46-
:syntax "Acked-by" :param :email-address :report-types #{:bug :patch :request}}
47-
{:id :owned-by :kind :trigger :action :set :attr :report/owned :scope :maintainer
54+
;; -by lines (credit the update to a third party)
55+
;; Reviewed-by is the kernel-style strong-review line, accepted as
56+
;; a plain syntax synonym: BONE's acked state is the strong
57+
;; approval (Confirmed, Approved, Reviewed-by:). Any participant's
58+
;; Reviewed-by both acks the report and is collected as a review
59+
;; trailer (:report/trailers); on a source that tightens :acked-by
60+
;; via a :scope override, denied lines are still collected as
61+
;; trailers.
62+
{:id :acked-by :kind :trigger :action :set :attr :report/acked :scope :user
63+
:syntax "Acked-by" :syntaxes ["Acked-by" "Reviewed-by"]
64+
:param :email-address :report-types #{:bug :patch :request}}
65+
{:id :owned-by :kind :trigger :action :set :attr :report/owned :scope :user
4866
:syntax "Owned-by" :param :email-address :report-types #{:bug :patch :request}}
49-
{:id :closed-by :kind :trigger :action :set :attr :report/closed :scope :maintainer
67+
{:id :closed-by :kind :trigger :action :set :attr :report/closed :scope :user
5068
:syntax "Closed-by" :param :email-address}
51-
;; Unset lines
52-
{:id :unacked :kind :trigger :action :unset :attr :report/acked :scope :setter-or-maintainer
53-
:syntax "Not acked" :report-types #{:bug :patch :request}}
54-
{:id :unowned :kind :trigger :action :unset :attr :report/owned :scope :setter-or-maintainer
55-
:syntax "Not owned" :report-types #{:bug :patch :request}}
56-
{:id :unclosed :kind :trigger :action :unset :attr :report/closed :scope :setter-or-maintainer
69+
;; Unset lines. :not-words derives the accepted syntaxes from the
70+
;; state's resolved vocabulary -- every word that can set the state
71+
;; (defaults and per-source synonyms alike) gets its "Not <word>"
72+
;; negation; :syntax remains the canonical form for display.
73+
{:id :unacked :kind :trigger :action :unset :attr :report/acked :scope :user
74+
:syntax "Not acked" :not-words :acked :report-types #{:bug :patch :request}}
75+
{:id :unowned :kind :trigger :action :unset :attr :report/owned :scope :user
76+
:syntax "Not owned" :not-words :owned :report-types #{:bug :patch :request}}
77+
;; Deliberately no :not-words here: closing words carry a close
78+
;; reason ("Not canceled"/"Not fixed" would not), and in loose mode
79+
;; a prose "Not fixed." would reopen reports. "Not closed" is the
80+
;; only reopening form.
81+
{:id :unclosed :kind :trigger :action :unset :attr :report/closed :scope :user
5782
:syntax "Not closed"}
5883
;; Closure relations -- backed by :rel/supersedes / :rel/duplicates;
5984
;; :attr kept for registry shape.
@@ -64,40 +89,40 @@
6489
;; pull-map key must distinguish them.
6590
{:id :superseded-by :kind :trigger :action :set-superseded :attr :rel/supersedes :scope :user
6691
:syntax "Superseded-by" :param :message-id :report-types #{:bug :patch :request}}
67-
{:id :unsuperseded-by :kind :trigger :action :unset-superseded :attr :rel/supersedes-from :scope :setter-or-maintainer
92+
{:id :unsuperseded-by :kind :trigger :action :unset-superseded :attr :rel/supersedes-from :scope :user
6893
:syntax "Not superseded-by" :param :message-id :report-types #{:bug :patch :request}}
6994
{:id :duplicate-of :kind :trigger :action :set-duplicate :attr :rel/duplicates :scope :user
7095
:syntax "Duplicate-of" :param :message-id :report-types #{:bug :patch :request}}
71-
{:id :unduplicate-of :kind :trigger :action :unset-duplicate :attr :rel/duplicates-from :scope :setter-or-maintainer
96+
{:id :unduplicate-of :kind :trigger :action :unset-duplicate :attr :rel/duplicates-from :scope :user
7297
:syntax "Not duplicate-of" :param :message-id :report-types #{:bug :patch :request}}
7398

7499
;; --- Annotations: property-set -------------------------------------------
75100
;; Bareword
76101
{:id :urgent :kind :annotation :action :set :attr :report/urgent :scope :user :words :urgent}
77102
{:id :important :kind :annotation :action :set :attr :report/important :scope :user :words :important}
78103
;; Unset lines for bareword annotations
79-
{:id :unurgent :kind :annotation :action :unset :attr :report/urgent :scope :setter-or-maintainer
80-
:syntax "Not urgent"}
81-
{:id :unimportant :kind :annotation :action :unset :attr :report/important :scope :setter-or-maintainer
82-
:syntax "Not important"}
104+
{:id :unurgent :kind :annotation :action :unset :attr :report/urgent :scope :user
105+
:syntax "Not urgent" :not-words :urgent}
106+
{:id :unimportant :kind :annotation :action :unset :attr :report/important :scope :user
107+
:syntax "Not important" :not-words :important}
83108
;; Deadline / expiry / topic
84109
{:id :deadline :kind :annotation :action :set-deadline :attr :report/deadline :scope :user
85110
:syntax "Deadline" :param :date-or-duration :report-types #{:bug :patch :request}}
86-
{:id :undeadline :kind :annotation :action :unset-deadline :attr :report/deadline :scope :setter-or-maintainer
111+
{:id :undeadline :kind :annotation :action :unset-deadline :attr :report/deadline :scope :user
87112
:syntax "No deadline" :report-types #{:bug :patch :request}}
88113
{:id :expiry :kind :annotation :action :set-expiry :attr :report/expiry :scope :user
89114
:syntax "Expiry" :param :date-or-duration}
90-
{:id :unexpiry :kind :annotation :action :unset-expiry :attr :report/expiry :scope :setter-or-maintainer
115+
{:id :unexpiry :kind :annotation :action :unset-expiry :attr :report/expiry :scope :user
91116
:syntax "No expiry"}
92117
{:id :topic :kind :annotation :action :set-topic :attr :report/topic :scope :user
93118
:syntax "Topic" :param :word}
94-
{:id :untopic :kind :annotation :action :unset-topic :attr :report/topic :scope :setter-or-maintainer
119+
{:id :untopic :kind :annotation :action :unset-topic :attr :report/topic :scope :user
95120
:syntax "No topic"}
96121
;; Supersedes -- inverse role of Superseded-by (posed on the replacement;
97122
;; current = :rel/to, target = :rel/from = the closed report).
98123
{:id :supersedes :kind :annotation :action :set-supersedes :attr :rel/supersedes :scope :user
99124
:syntax "Supersedes" :param :message-id :report-types #{:bug :patch :request}}
100-
{:id :unsupersedes :kind :annotation :action :unset-supersedes :attr :rel/supersedes-to :scope :setter-or-maintainer
125+
{:id :unsupersedes :kind :annotation :action :unset-supersedes :attr :rel/supersedes-to :scope :user
101126
:syntax "Not supersedes" :param :message-id :report-types #{:bug :patch :request}}
102127
;; Related-to -- neutral cross-reference (no closure, multi-target,
103128
;; symmetric canonicalised by :rel/id). Multi-target makes a clean
@@ -175,10 +200,12 @@
175200
:report/deadline :report/deadline-value
176201
:report/expiry :report/expiry-value})
177202

178-
;; Commands accepting :scope :setter-or-maintainer:
203+
;; Commands that support a :setter-or-maintainer scope override:
179204
;; - unset lines keyed on a setter-ref attribute, plus
180205
;; - explicit relation-backed unsets (:unsuperseded-by, :unsupersedes,
181206
;; :unduplicate-of).
207+
;; All of them default to :scope :user; this set only matters when a
208+
;; source tightens one back via config.
182209
(def setter-scoped-command-ids
183210
(into #{:unsuperseded-by :unsupersedes :unduplicate-of}
184211
(comp (filter #(str/starts-with? (name (:action %)) "unset"))

src/bone/common.clj

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
(def bone-format
2626
"BONE export format version. Bump when the JSON/Org export shape changes."
27-
"0.9.4")
27+
"0.9.5")
2828

2929
(def bone-schema
3030
(edn/read-string (slurp (io/resource "bone-schema.edn"))))
@@ -329,6 +329,47 @@
329329
(some-> (or (:email/body-text email) (:email/body-text-from-html email))
330330
strip-signature))
331331

332+
(def trailer-keys
333+
"Person trailers collected from replies to a patch, b4-style:
334+
lowercase key -> canonical capitalization. Distinct from the BONE
335+
command layer (`Acked-by:` as a maintainer proxy command): trailers
336+
are collected verbatim from anyone, for clients to fold into the
337+
commit message when applying the patch."
338+
{"acked-by" "Acked-by"
339+
"reviewed-by" "Reviewed-by"
340+
"tested-by" "Tested-by"
341+
"reported-by" "Reported-by"
342+
"suggested-by" "Suggested-by"
343+
"signed-off-by" "Signed-off-by"
344+
"co-developed-by" "Co-developed-by"})
345+
346+
(def ^:private patch-start-line-re
347+
"First line of a patch pasted inline in a reply (mirrors detect's
348+
inline-patch-start-patterns, which bone.detect cannot share without
349+
inverting the dependency). extract-trailers stops there, so a fixup
350+
pasted below the reply text cannot leak its own Signed-off-by."
351+
#"^(?:From [0-9a-f]{40} |diff --git |--- a/)")
352+
353+
(defn extract-trailers
354+
"Collect git person trailers from a reply body.
355+
Returns a distinct vector of \"Key: Name <addr>\" strings for the
356+
lines whose key starts at column 0 and is in `trailer-keys`, and
357+
whose value contains an @ (so prose like \"Tested-by: nobody yet\"
358+
is skipped). Quoted lines never match, their key sitting behind
359+
\">\". Scanning stops at the first line of a pasted patch, the way
360+
b4 does. Keys are canonicalized, values trimmed. Nil-safe on body."
361+
[body]
362+
(when body
363+
(->> (str/split-lines body)
364+
(take-while #(not (re-find patch-start-line-re %)))
365+
(keep (fn [line]
366+
(when-let [[_ k v] (re-matches #"([A-Za-z][A-Za-z-]*):\s*(\S.*?)\s*" line)]
367+
(when-let [ck (get trailer-keys (str/lower-case k))]
368+
(when (str/includes? v "@")
369+
(str ck ": " v))))))
370+
distinct
371+
vec)))
372+
332373
;; ---------------------------------------------------------------------------
333374
;; Date formatting
334375
;; ---------------------------------------------------------------------------
@@ -696,6 +737,12 @@
696737
(def type->plural (into {} (map (juxt :type :plural)) report-type-spec))
697738

698739
(def default-commands
740+
;; No bare "Reviewed" in :acked -- in loose mode a reply opener like
741+
;; "Reviewed v2 and found problems." would ack with inverse polarity
742+
;; (a review is not an approval, unlike Confirmed/Approved). The
743+
;; kernel idiom is the full Reviewed-by: line, a syntax synonym of
744+
;; Acked-by: in the registry; sources that want the bareword can add
745+
;; it via :words.
699746
{:acked ["Acked" "Confirmed" "Approved"]
700747
:owned ["Owned"]
701748
:closed ["Canceled" "Cancelled" "Closed" "Expired"
@@ -1104,6 +1151,7 @@
11041151
{:report/expiry [:email/author-address :email/date-sent]}
11051152
:report/expiry-value
11061153
:report/has-ics :report/has-text-attachments
1154+
:report/trailers
11071155
:report/last-activity :report/last-activity-address :report/descendants :report/updated-at
11081156
{:rel/_from [:rel/kind :rel/active? :rel/setter :rel/posed-at :rel/value
11091157
{:rel/to [:db/id :report/type :report/message-id

0 commit comments

Comments
 (0)