commenter 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/CLAUDE.md +17 -7
- data/CONTEXT.md +27 -0
- data/README.adoc +92 -17
- data/commenter.gemspec +1 -0
- data/lib/commenter/ballot.rb +67 -0
- data/lib/commenter/ballot_report.rb +60 -0
- data/lib/commenter/cli.rb +62 -43
- data/lib/commenter/comment.rb +103 -126
- data/lib/commenter/comment_locality.rb +18 -0
- data/lib/commenter/comment_sheet.rb +31 -63
- data/lib/commenter/comment_type.rb +39 -0
- data/lib/commenter/disposition_status.rb +37 -0
- data/lib/commenter/filler.rb +91 -59
- data/lib/commenter/github_info.rb +23 -0
- data/lib/commenter/github_integration.rb +17 -71
- data/lib/commenter/github_session.rb +35 -0
- data/lib/commenter/parser/osd_xlsx_parser.rb +1 -12
- data/lib/commenter/parser/track_change_docx_parser.rb +330 -0
- data/lib/commenter/parser.rb +10 -13
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +16 -5
- data/spec/commenter/ballot_report_spec.rb +62 -0
- data/spec/commenter/ballot_spec.rb +68 -0
- data/spec/commenter/cli_spec.rb +41 -0
- data/spec/commenter/comment_sheet_spec.rb +35 -10
- data/spec/commenter/comment_spec.rb +22 -9
- data/spec/commenter/comment_type_spec.rb +45 -0
- data/spec/commenter/disposition_status_spec.rb +40 -0
- data/spec/commenter/filler_spec.rb +119 -0
- data/spec/commenter/github_integration_spec.rb +65 -32
- data/spec/commenter/osd_xlsx_parser_spec.rb +3 -2
- data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
- data/spec/support/redline_docx_builder.rb +95 -0
- data/spec/support/xlsx_builder.rb +17 -12
- metadata +32 -2
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
6
|
+
module Commenter
|
|
7
|
+
class Parser
|
|
8
|
+
# Extracts tracked changes from a redlined Word document into an ISO
|
|
9
|
+
# 2012-03 comment sheet.
|
|
10
|
+
#
|
|
11
|
+
# Captured track changes (w:ins, w:del, w:moveFrom, w:moveTo):
|
|
12
|
+
# - proposed_change renders the change itself, e.g. +Insert: "text"+
|
|
13
|
+
# - locality.clause is resolved from the nearest preceding heading
|
|
14
|
+
# - locality.element is resolved from caption/inline references
|
|
15
|
+
# (Table N, Figure N, Formula (N), NOTE n) within the current clause
|
|
16
|
+
# - observations can be stamped via the observations or accept_all options
|
|
17
|
+
#
|
|
18
|
+
# Reviewer comment threads (word/comments.xml w:comment) are emitted
|
|
19
|
+
# after the track changes with -CNNN ids: the remark verbatim in
|
|
20
|
+
# comments, its instruction reworded as the proposed change, and empty
|
|
21
|
+
# observations for the owner to draft.
|
|
22
|
+
#
|
|
23
|
+
# Self-closing markers (paragraph-mark insertions inside rPr) carry no
|
|
24
|
+
# content and are skipped.
|
|
25
|
+
#
|
|
26
|
+
# word/document.xml is streamed with Nokogiri::XML::Reader because redline
|
|
27
|
+
# documents can exceed 100 MB.
|
|
28
|
+
class TrackChangeDocxParser
|
|
29
|
+
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
30
|
+
|
|
31
|
+
HEADING_STYLE = /\A(?:Heading[1-6]|h[2-5]annex\d*|ANNEX|BaseHeading)\z/i
|
|
32
|
+
KIND_COMMENT = { "ins" => "insertion", "del" => "deletion", "moveFrom" => "move (from)",
|
|
33
|
+
"moveTo" => "move (to)" }.freeze
|
|
34
|
+
KIND_LABEL = { "ins" => "Insert", "del" => "Delete", "moveFrom" => "Move from",
|
|
35
|
+
"moveTo" => "Move to" }.freeze
|
|
36
|
+
|
|
37
|
+
DEFAULT_BODY = "CS"
|
|
38
|
+
WHOLE_DOCUMENT = "_whole document"
|
|
39
|
+
ACCEPT_ALL = "Accepted. Tracked change accepted."
|
|
40
|
+
|
|
41
|
+
attr_reader :skipped_markers
|
|
42
|
+
|
|
43
|
+
def parse(path, options = {})
|
|
44
|
+
body = options[:body] || DEFAULT_BODY
|
|
45
|
+
changes = []
|
|
46
|
+
remarks = {}
|
|
47
|
+
anchors = {}
|
|
48
|
+
@skipped_markers = 0
|
|
49
|
+
|
|
50
|
+
Zip::File.open(path) do |zip|
|
|
51
|
+
entry = zip.glob("word/document.xml").first
|
|
52
|
+
raise Commenter::Error, "word/document.xml not found in #{path}" unless entry
|
|
53
|
+
|
|
54
|
+
remarks = read_remarks(zip)
|
|
55
|
+
changes = entry.get_input_stream { |stream| stream_changes(stream, anchors) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
CommentSheet.new(
|
|
59
|
+
version: "2012-03",
|
|
60
|
+
date: sheet_date(changes),
|
|
61
|
+
document: options[:document],
|
|
62
|
+
stage: options[:stage],
|
|
63
|
+
comments: build_change_comments(changes, body, options) +
|
|
64
|
+
build_remark_comments(remarks, anchors, body, options)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Turns a reviewer's remark phrased as a request ("Please remove this
|
|
69
|
+
# NOTE...") into the corresponding proposed change ("Remove this
|
|
70
|
+
# NOTE...").
|
|
71
|
+
def self.reword_remark(text)
|
|
72
|
+
text.to_s.strip.sub(/\APlease\s+/i, "").sub(/\A[a-z]/, &:upcase)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def read_remarks(zip)
|
|
78
|
+
entry = zip.glob("word/comments.xml").first
|
|
79
|
+
return {} unless entry
|
|
80
|
+
|
|
81
|
+
document = entry.get_input_stream { |stream| Nokogiri::XML(stream) }
|
|
82
|
+
document.xpath("//w:comment", "w" => W_NS).each_with_object({}) do |node, remarks|
|
|
83
|
+
remarks[node["w:id"]] = {
|
|
84
|
+
author: node["w:author"],
|
|
85
|
+
date: node["w:date"],
|
|
86
|
+
text: node.xpath(".//w:t", "w" => W_NS).map(&:text).join
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sheet_date(changes)
|
|
92
|
+
dates = changes.filter_map { |change| change[:date].to_s[/\A\d{4}-\d{2}-\d{2}/] }
|
|
93
|
+
dates.max
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_change_comments(changes, body, options)
|
|
97
|
+
changes.each_with_index.map do |change, index|
|
|
98
|
+
kind = change[:kind]
|
|
99
|
+
Comment.new(
|
|
100
|
+
id: format("%<body>s-%03<index>d", body: body, index: index + 1),
|
|
101
|
+
body: body,
|
|
102
|
+
locality: { clause: change[:clause], line_number: nil, element: change[:element] },
|
|
103
|
+
type: CommentType.full_name(options[:type] || "te"),
|
|
104
|
+
comments: "Track change (#{KIND_COMMENT.fetch(kind, kind)})",
|
|
105
|
+
proposed_change: "#{KIND_LABEL.fetch(kind, kind)}: \"#{change[:text].strip}\"",
|
|
106
|
+
observations: observations_for(options)
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_remark_comments(remarks, anchors, body, options)
|
|
112
|
+
remarks.keys.sort_by(&:to_i).each_with_index.map do |remark_id, index|
|
|
113
|
+
remark = remarks[remark_id]
|
|
114
|
+
anchor = anchors[remark_id] || {}
|
|
115
|
+
clause = anchor[:clause].to_s.empty? ? WHOLE_DOCUMENT : anchor[:clause]
|
|
116
|
+
Comment.new(
|
|
117
|
+
id: format("%<body>s-C%03<index>d", body: body, index: index + 1),
|
|
118
|
+
body: body,
|
|
119
|
+
locality: { clause: clause, line_number: nil, element: anchor[:element] },
|
|
120
|
+
type: options[:remark_type] || "ed",
|
|
121
|
+
comments: remark[:text],
|
|
122
|
+
proposed_change: self.class.reword_remark(remark[:text]),
|
|
123
|
+
observations: nil
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def observations_for(options)
|
|
129
|
+
return nil if options[:exclude_observations]
|
|
130
|
+
|
|
131
|
+
options[:observations] || (options[:accept_all] ? ACCEPT_ALL : nil)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def stream_changes(io, anchors = {})
|
|
135
|
+
changes = []
|
|
136
|
+
change_stack = []
|
|
137
|
+
paragraph = ParagraphState.new
|
|
138
|
+
|
|
139
|
+
Nokogiri::XML::Reader(io).each do |reader|
|
|
140
|
+
case reader.node_type
|
|
141
|
+
when Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
142
|
+
handle_start(reader, change_stack, paragraph, anchors)
|
|
143
|
+
when Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE
|
|
144
|
+
handle_text(reader, change_stack, paragraph)
|
|
145
|
+
when Nokogiri::XML::Reader::TYPE_END_ELEMENT
|
|
146
|
+
handle_end(reader, change_stack, paragraph, changes)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
changes
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def handle_start(reader, change_stack, paragraph, anchors = {})
|
|
154
|
+
case reader.local_name
|
|
155
|
+
when "p"
|
|
156
|
+
paragraph.begin_paragraph
|
|
157
|
+
when "pStyle"
|
|
158
|
+
paragraph.style = reader.attribute("w:val") || reader.attribute("val")
|
|
159
|
+
when "ins", "del", "moveFrom", "moveTo"
|
|
160
|
+
start_change(reader, change_stack, paragraph)
|
|
161
|
+
when "commentRangeStart"
|
|
162
|
+
anchor_remark(reader, paragraph, anchors)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def anchor_remark(reader, paragraph, anchors)
|
|
167
|
+
id = reader.attribute("w:id")
|
|
168
|
+
anchors[id] = {
|
|
169
|
+
clause: paragraph.pending_clause || paragraph.clause,
|
|
170
|
+
element: paragraph.pending_element || paragraph.element
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def start_change(reader, change_stack, paragraph)
|
|
175
|
+
if reader.empty_element?
|
|
176
|
+
@skipped_markers += 1
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
change_stack.push(
|
|
181
|
+
kind: reader.local_name,
|
|
182
|
+
id: reader.attribute("w:id"),
|
|
183
|
+
author: reader.attribute("w:author"),
|
|
184
|
+
date: reader.attribute("w:date"),
|
|
185
|
+
text: +"",
|
|
186
|
+
clause: paragraph.clause,
|
|
187
|
+
element: paragraph.element
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def handle_text(reader, change_stack, paragraph)
|
|
192
|
+
if (current = change_stack.last)
|
|
193
|
+
current[:text] << reader.value
|
|
194
|
+
else
|
|
195
|
+
paragraph.append_text(reader.value)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def handle_end(reader, change_stack, paragraph, changes)
|
|
200
|
+
case reader.local_name
|
|
201
|
+
when "p"
|
|
202
|
+
paragraph.commit_paragraph
|
|
203
|
+
when "ins", "del", "moveFrom", "moveTo"
|
|
204
|
+
change = change_stack.pop
|
|
205
|
+
changes << finalize_change(change, paragraph) if change
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def finalize_change(change, paragraph)
|
|
210
|
+
# A change inside a heading or caption paragraph belongs to that
|
|
211
|
+
# paragraph, which is not committed yet at this point.
|
|
212
|
+
change[:clause] = paragraph.pending_clause || change[:clause]
|
|
213
|
+
change[:element] = paragraph.pending_element || change[:element]
|
|
214
|
+
change[:clause] = WHOLE_DOCUMENT if change[:clause].to_s.empty?
|
|
215
|
+
change
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Tracks the current paragraph's style and text to resolve the locality
|
|
219
|
+
# context of a change:
|
|
220
|
+
#
|
|
221
|
+
# - clause: the clause number of the most recent heading paragraph; an
|
|
222
|
+
# unnumbered sub-heading inherits its parent heading's clause
|
|
223
|
+
# - element: the nearest Table/Figure/Formula/NOTE reference, scoped to
|
|
224
|
+
# the current clause (caption paragraphs and inline mentions both
|
|
225
|
+
# count)
|
|
226
|
+
class ParagraphState
|
|
227
|
+
NUMBERED_CLAUSE = /\A(?:\d+(?:\.\d+)*|Annex\s+[A-Z](?:\.\d+)*|Bibliography|Foreword|Introduction)\z/i
|
|
228
|
+
ELEMENT_START = /\A\s*(NOTE\s+\d+|NOTE(?=\s*[—:-])|Table\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|Figure\s+(?:[A-Z]\.)?\d+(?:\.\d+)*)\b/i
|
|
229
|
+
FORMULA = /\b(Formula\s*\(\d+(?:\.\d+)*\))/
|
|
230
|
+
ELEMENT_ANY = /\b(Table\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|Figure\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|NOTE\s+\d+)\b/i
|
|
231
|
+
|
|
232
|
+
attr_writer :style
|
|
233
|
+
attr_reader :clause, :element
|
|
234
|
+
|
|
235
|
+
def initialize
|
|
236
|
+
@style = nil
|
|
237
|
+
@text = +""
|
|
238
|
+
@clause = ""
|
|
239
|
+
@element = nil
|
|
240
|
+
@heading_stack = []
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def begin_paragraph
|
|
244
|
+
@style = nil
|
|
245
|
+
@text = +""
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def append_text(value)
|
|
249
|
+
@text << value
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def pending_clause
|
|
253
|
+
return unless heading_paragraph?
|
|
254
|
+
|
|
255
|
+
clause = self.class.clause_for(@text)
|
|
256
|
+
clause unless clause.empty?
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def pending_element
|
|
260
|
+
self.class.element_for(@text)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def commit_paragraph
|
|
264
|
+
if heading_paragraph?
|
|
265
|
+
commit_heading
|
|
266
|
+
else
|
|
267
|
+
@element = self.class.element_for(@text) || @element
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def heading_paragraph?
|
|
272
|
+
@style&.match?(HEADING_STYLE)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def self.numbered_clause?(clause)
|
|
276
|
+
clause.to_s.match?(NUMBERED_CLAUSE)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Extracts the clause identifier from heading text: the leading number
|
|
280
|
+
# ("4.2.1 Thermodynamic ..." -> "4.2.1"), an annex reference, or a
|
|
281
|
+
# well-known unnumbered section. Runs may be concatenated without
|
|
282
|
+
# spaces ("4.2.1Thermodynamic"), so the number is matched with a
|
|
283
|
+
# lookahead.
|
|
284
|
+
def self.clause_for(text)
|
|
285
|
+
s = text.to_s.strip
|
|
286
|
+
return "" if s.empty?
|
|
287
|
+
|
|
288
|
+
return Regexp.last_match(1) if s =~ /\A\s*(Annex\s+[A-Z](?:\.\d+)*)\b/i
|
|
289
|
+
return Regexp.last_match(1) if s =~ /\A\s*(Bibliography|Foreword|Introduction)\b/i
|
|
290
|
+
return Regexp.last_match(1) if s =~ /\A\s*((?:\d+\.)*\d+)(?=[A-Z\s])/
|
|
291
|
+
return Regexp.last_match(1) if s =~ /\A\s*((?:\d+\.)*\d+)\b/
|
|
292
|
+
|
|
293
|
+
s
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Resolves the element reference of a paragraph: a caption opening the
|
|
297
|
+
# paragraph ("Table 3 — ...", "NOTE 2 ...", "Formula (9):") wins over an
|
|
298
|
+
# inline mention ("the values in Table 5 shall ...").
|
|
299
|
+
def self.element_for(text)
|
|
300
|
+
s = text.to_s
|
|
301
|
+
match = s.match(ELEMENT_START) || s.match(FORMULA) || s.match(ELEMENT_ANY)
|
|
302
|
+
match[1].squeeze(" ").strip if match
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def commit_heading
|
|
308
|
+
level = heading_level
|
|
309
|
+
clause = self.class.clause_for(@text)
|
|
310
|
+
clause = inherited_clause(level) unless self.class.numbered_clause?(clause)
|
|
311
|
+
@heading_stack.pop while @heading_stack.last && @heading_stack.last[0] >= level
|
|
312
|
+
@heading_stack << [level, clause]
|
|
313
|
+
@clause = clause unless clause.empty?
|
|
314
|
+
# Element references belong to the clause they appear in.
|
|
315
|
+
@element = nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def inherited_clause(level)
|
|
319
|
+
parent = @heading_stack.reverse.find { |(lvl, _)| lvl < level }
|
|
320
|
+
parent ? parent[1] : ""
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def heading_level
|
|
324
|
+
m = @style.match(/\A(?:Heading|h)(\d+)/i)
|
|
325
|
+
m ? m[1].to_i : 1
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
data/lib/commenter/parser.rb
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "docx"
|
|
4
4
|
require "pathname"
|
|
5
|
-
require_relative "comment_sheet"
|
|
6
|
-
require_relative "comment"
|
|
7
|
-
require_relative "parser/osd_xlsx_parser"
|
|
8
5
|
|
|
9
6
|
module Commenter
|
|
10
7
|
class Parser
|
|
8
|
+
autoload :OsdXlsxParser, "commenter/parser/osd_xlsx_parser"
|
|
9
|
+
autoload :TrackChangeDocxParser, "commenter/parser/track_change_docx_parser"
|
|
10
|
+
|
|
11
11
|
def parse(input_path, options = {})
|
|
12
12
|
format = detect_format(input_path, options)
|
|
13
13
|
|
|
@@ -16,6 +16,8 @@ module Commenter
|
|
|
16
16
|
parse_docx(input_path, options)
|
|
17
17
|
when :xlsx
|
|
18
18
|
parse_xlsx(input_path, options)
|
|
19
|
+
when :redline
|
|
20
|
+
parse_redline(input_path, options)
|
|
19
21
|
else
|
|
20
22
|
raise "Unsupported file format: #{input_path}. Supported formats: .docx, .xlsx"
|
|
21
23
|
end
|
|
@@ -38,6 +40,10 @@ module Commenter
|
|
|
38
40
|
OsdXlsxParser.new.parse(xlsx_path, options)
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
def parse_redline(docx_path, options)
|
|
44
|
+
TrackChangeDocxParser.new.parse(docx_path, options)
|
|
45
|
+
end
|
|
46
|
+
|
|
41
47
|
def parse_docx(docx_path, options)
|
|
42
48
|
doc = Docx::Document.open(docx_path)
|
|
43
49
|
|
|
@@ -83,7 +89,7 @@ module Commenter
|
|
|
83
89
|
clause: presence(cells[2]),
|
|
84
90
|
element: presence(cells[3])
|
|
85
91
|
},
|
|
86
|
-
type:
|
|
92
|
+
type: CommentType.full_name(cells[4]),
|
|
87
93
|
comments: cells[5] || "",
|
|
88
94
|
proposed_change: presence(cells[6]),
|
|
89
95
|
user_name: cells[0].to_s.strip
|
|
@@ -117,15 +123,6 @@ module Commenter
|
|
|
117
123
|
value && !value.empty? ? value : nil
|
|
118
124
|
end
|
|
119
125
|
|
|
120
|
-
def normalize_type(type_str)
|
|
121
|
-
case type_str.to_s.strip.downcase
|
|
122
|
-
when "editorial" then "ed"
|
|
123
|
-
when "technical" then "te"
|
|
124
|
-
when "general" then "ge"
|
|
125
|
-
else type_str.to_s.strip
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
126
|
def extract_metadata(doc)
|
|
130
127
|
metadata = { date: nil, document: nil, project: nil }
|
|
131
128
|
|
data/lib/commenter/version.rb
CHANGED
data/lib/commenter.rb
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "commenter/version"
|
|
4
|
-
require_relative "commenter/comment"
|
|
5
|
-
require_relative "commenter/comment_sheet"
|
|
6
|
-
require_relative "commenter/parser"
|
|
7
|
-
require_relative "commenter/filler"
|
|
8
|
-
require_relative "commenter/github_integration"
|
|
9
4
|
|
|
5
|
+
# Commenter converts ISO comment sheets (DOCX/XLSX) to structured YAML and
|
|
6
|
+
# syncs comments to GitHub issues.
|
|
10
7
|
module Commenter
|
|
11
8
|
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
autoload :CommentType, "commenter/comment_type"
|
|
11
|
+
autoload :CommentLocality, "commenter/comment_locality"
|
|
12
|
+
autoload :GithubInfo, "commenter/github_info"
|
|
13
|
+
autoload :DispositionStatus, "commenter/disposition_status"
|
|
14
|
+
autoload :Ballot, "commenter/ballot"
|
|
15
|
+
autoload :BallotReport, "commenter/ballot_report"
|
|
16
|
+
autoload :Comment, "commenter/comment"
|
|
17
|
+
autoload :CommentSheet, "commenter/comment_sheet"
|
|
18
|
+
autoload :GitHubSession, "commenter/github_session"
|
|
19
|
+
autoload :Parser, "commenter/parser"
|
|
20
|
+
autoload :Filler, "commenter/filler"
|
|
21
|
+
autoload :GitHubIssueCreator, "commenter/github_integration"
|
|
22
|
+
autoload :GitHubIssueRetriever, "commenter/github_integration"
|
|
12
23
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Commenter::BallotReport do
|
|
6
|
+
def comment(body:, observations: nil, resolution_status: nil, github: {}, type: "technical")
|
|
7
|
+
Commenter::Comment.new(
|
|
8
|
+
id: "#{body}-1", body: body, type: type,
|
|
9
|
+
comments: "A comment", observations: observations,
|
|
10
|
+
resolution_status: resolution_status, github: github
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
let(:sheet) do
|
|
15
|
+
Commenter::CommentSheet.new(comments: [
|
|
16
|
+
comment(body: "DE", observations: "Accepted."),
|
|
17
|
+
comment(body: "DE", observations: "Accept with modifications"),
|
|
18
|
+
comment(body: "US", observations: "Noted"),
|
|
19
|
+
comment(body: "US", observations: "Rejected"),
|
|
20
|
+
comment(body: "US"),
|
|
21
|
+
comment(body: "CS", resolution_status: "Not accepted"),
|
|
22
|
+
comment(body: "CS", github: { status: "open" })
|
|
23
|
+
])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe ".status_for" do
|
|
27
|
+
it "prefers observation text, then resolution status, then GitHub state" do
|
|
28
|
+
comments = sheet.comments
|
|
29
|
+
expect(described_class.status_for(comments[0])).to eq(:accepted)
|
|
30
|
+
expect(described_class.status_for(comments[4])).to eq(:undecided)
|
|
31
|
+
expect(described_class.status_for(comments[5])).to eq(:rejected)
|
|
32
|
+
expect(described_class.status_for(comments[6])).to eq(:open)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe ".counts" do
|
|
37
|
+
it "counts per member body, disposition, and type" do
|
|
38
|
+
counts = described_class.counts(sheet)
|
|
39
|
+
|
|
40
|
+
expect(counts[:total]).to eq(7)
|
|
41
|
+
expect(counts[:bodies]["DE"][:total]).to eq(2)
|
|
42
|
+
expect(counts[:bodies]["DE"][:accepted]).to eq(1)
|
|
43
|
+
expect(counts[:bodies]["DE"][:accept_with_modifications]).to eq(1)
|
|
44
|
+
expect(counts[:bodies]["US"][:noted]).to eq(1)
|
|
45
|
+
expect(counts[:bodies]["US"][:rejected]).to eq(1)
|
|
46
|
+
expect(counts[:bodies]["US"][:undecided]).to eq(1)
|
|
47
|
+
expect(counts[:bodies]["CS"][:open]).to eq(1)
|
|
48
|
+
expect(counts[:types]["technical"]).to eq(7)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe ".to_markdown" do
|
|
53
|
+
it "renders a ballot report table with totals" do
|
|
54
|
+
markdown = described_class.to_markdown(sheet)
|
|
55
|
+
|
|
56
|
+
expect(markdown).to start_with("| Body | Total | Accepted | AWM | Noted | Rejected | TODO | Open | Undecided |")
|
|
57
|
+
expect(markdown).to include("| DE | 2 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |")
|
|
58
|
+
expect(markdown).to include("| **Total** | **7** | **1** | **1** | **1** | **2** | **0** | **1** | **1** |")
|
|
59
|
+
expect(markdown).to include("Comment types: technical 7")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Commenter::Ballot do
|
|
6
|
+
def sheet(body:, ids:, document: nil, stage: nil)
|
|
7
|
+
Commenter::CommentSheet.new(
|
|
8
|
+
version: "2012-03",
|
|
9
|
+
document: document,
|
|
10
|
+
stage: stage,
|
|
11
|
+
comments: ids.map do |id|
|
|
12
|
+
Commenter::Comment.new(id: id, body: body, comments: "Comment #{id} from #{body}")
|
|
13
|
+
end
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "combines comments from all sheets in input order" do
|
|
18
|
+
merged = described_class.new([
|
|
19
|
+
sheet(body: "DE", ids: %w[DE-001 DE-002]),
|
|
20
|
+
sheet(body: "US", ids: %w[US-001])
|
|
21
|
+
]).merge
|
|
22
|
+
|
|
23
|
+
expect(merged.comments.map(&:id)).to eq(%w[DE-001 DE-002 US-001])
|
|
24
|
+
expect(merged.comments.map(&:body)).to eq(%w[DE DE US])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "takes metadata from the first sheet that provides it" do
|
|
28
|
+
merged = described_class.new([
|
|
29
|
+
sheet(body: "DE", ids: %w[DE-001], stage: nil),
|
|
30
|
+
sheet(body: "US", ids: %w[US-001], document: "ISO 2533:2026", stage: "DIS")
|
|
31
|
+
]).merge
|
|
32
|
+
|
|
33
|
+
expect(merged.version).to eq("2012-03")
|
|
34
|
+
expect(merged.document).to eq("ISO 2533:2026")
|
|
35
|
+
expect(merged.stage).to eq("DIS")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "silently deduplicates identical comments sharing an ID" do
|
|
39
|
+
duplicate = Commenter::Comment.new(id: "DE-001", body: "DE", comments: "Same text", proposed_change: "Same change")
|
|
40
|
+
|
|
41
|
+
merged = described_class.new([
|
|
42
|
+
Commenter::CommentSheet.new(comments: [duplicate]),
|
|
43
|
+
Commenter::CommentSheet.new(comments: [
|
|
44
|
+
Commenter::Comment.new(id: "DE-001", body: "DE", comments: "Same text",
|
|
45
|
+
proposed_change: "Same change")
|
|
46
|
+
])
|
|
47
|
+
]).merge
|
|
48
|
+
|
|
49
|
+
expect(merged.comments.map(&:id)).to eq(["DE-001"])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "raises when the same ID carries differing content" do
|
|
53
|
+
conflicting = Commenter::Comment.new(id: "DE-001", body: "DE", comments: "Different text entirely")
|
|
54
|
+
|
|
55
|
+
expect do
|
|
56
|
+
described_class.new([
|
|
57
|
+
sheet(body: "DE", ids: %w[DE-001]),
|
|
58
|
+
Commenter::CommentSheet.new(comments: [conflicting])
|
|
59
|
+
]).merge
|
|
60
|
+
end.to raise_error(Commenter::Error, /DE-001/)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "returns an empty sheet when no input sheets are given" do
|
|
64
|
+
merged = described_class.new([]).merge
|
|
65
|
+
|
|
66
|
+
expect(merged.comments).to eq([])
|
|
67
|
+
end
|
|
68
|
+
end
|
data/spec/commenter/cli_spec.rb
CHANGED
|
@@ -32,4 +32,45 @@ RSpec.describe Commenter::Cli do
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
describe "#merge" do
|
|
37
|
+
it "writes a combined sheet with schema reference" do
|
|
38
|
+
Dir.mktmpdir do |dir|
|
|
39
|
+
one = File.join(dir, "de.yaml")
|
|
40
|
+
two = File.join(dir, "us.yaml")
|
|
41
|
+
File.write(one, { "version" => "2012-03", "comments" => [
|
|
42
|
+
{ "id" => "DE-001", "body" => "DE", "comments" => "First" }
|
|
43
|
+
] }.to_yaml)
|
|
44
|
+
File.write(two, { "comments" => [
|
|
45
|
+
{ "id" => "US-001", "body" => "US", "comments" => "Second" }
|
|
46
|
+
] }.to_yaml)
|
|
47
|
+
output = File.join(dir, "out", "merged.yaml")
|
|
48
|
+
schema_dir = File.join(dir, "out", "schema")
|
|
49
|
+
|
|
50
|
+
described_class.start(["merge", one, two, "--output", output, "--schema-dir", schema_dir])
|
|
51
|
+
|
|
52
|
+
data = YAML.safe_load_file(output)
|
|
53
|
+
expect(data["comments"].map { |comment| comment["id"] }).to eq(%w[DE-001 US-001])
|
|
54
|
+
expect(File).to exist(File.join(schema_dir, "iso_comment_2012-03.yaml"))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe "#stats" do
|
|
60
|
+
it "prints a markdown disposition report" do
|
|
61
|
+
Dir.mktmpdir do |dir|
|
|
62
|
+
input = File.join(dir, "comments.yaml")
|
|
63
|
+
File.write(input, { "comments" => [
|
|
64
|
+
{ "id" => "DE-001", "body" => "DE", "comments" => "A", "observations" => "Accepted." },
|
|
65
|
+
{ "id" => "US-001", "body" => "US", "comments" => "B", "observations" => "Noted" }
|
|
66
|
+
] }.to_yaml)
|
|
67
|
+
|
|
68
|
+
expect { described_class.start(["stats", input]) }
|
|
69
|
+
.to output(a_string_including(
|
|
70
|
+
"| DE | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |",
|
|
71
|
+
"| US | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |"
|
|
72
|
+
)).to_stdout
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
35
76
|
end
|
|
@@ -115,21 +115,21 @@ RSpec.describe Commenter::CommentSheet do
|
|
|
115
115
|
|
|
116
116
|
hash = sheet.to_h
|
|
117
117
|
|
|
118
|
-
expect(hash[
|
|
119
|
-
expect(hash[
|
|
120
|
-
expect(hash[
|
|
121
|
-
expect(hash[
|
|
122
|
-
expect(hash[
|
|
123
|
-
expect(hash[
|
|
124
|
-
expect(hash[
|
|
118
|
+
expect(hash["version"]).to eq("2012-03")
|
|
119
|
+
expect(hash["date"]).to eq("2024-06-04")
|
|
120
|
+
expect(hash["document"]).to eq("Test Document")
|
|
121
|
+
expect(hash["project"]).to eq("Test Project")
|
|
122
|
+
expect(hash["stage"]).to eq("DIS")
|
|
123
|
+
expect(hash["comments"]).to be_an(Array)
|
|
124
|
+
expect(hash["comments"].first).to be_a(Hash)
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
-
it "
|
|
127
|
+
it "omits unset attributes from the hash" do
|
|
128
128
|
sheet = described_class.new({})
|
|
129
129
|
hash = sheet.to_h
|
|
130
130
|
|
|
131
|
-
expect(hash).
|
|
132
|
-
expect(
|
|
131
|
+
expect(hash).not_to have_key("stage")
|
|
132
|
+
expect(sheet.version).to eq("2012-03")
|
|
133
133
|
end
|
|
134
134
|
end
|
|
135
135
|
|
|
@@ -169,6 +169,31 @@ RSpec.describe Commenter::CommentSheet do
|
|
|
169
169
|
expect(sheet.comments.length).to eq(1)
|
|
170
170
|
end
|
|
171
171
|
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
RSpec.describe Commenter::CommentSheet do
|
|
175
|
+
describe "#schema_name" do
|
|
176
|
+
it "selects the schema for each sheet version" do
|
|
177
|
+
expect(described_class.new(version: "osd").schema_name).to eq("iso_comment_osd.yaml")
|
|
178
|
+
expect(described_class.new(version: "2012-03").schema_name).to eq("iso_comment_2012-03.yaml")
|
|
179
|
+
expect(described_class.new({}).schema_name).to eq("iso_comment_2012-03.yaml")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
describe "#to_yaml_document" do
|
|
184
|
+
it "prepends a header referencing the schema matching the version" do
|
|
185
|
+
document = described_class.new(version: "osd", comments: []).to_yaml_document
|
|
186
|
+
|
|
187
|
+
expect(document.lines.first).to eq("# yaml-language-server: $schema=schema/iso_comment_osd.yaml\n")
|
|
188
|
+
expect(YAML.safe_load(document)).to include("version" => "osd")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "accepts a schema directory" do
|
|
192
|
+
document = described_class.new(comments: []).to_yaml_document("schemas")
|
|
193
|
+
|
|
194
|
+
expect(document.lines.first).to eq("# yaml-language-server: $schema=schemas/iso_comment_2012-03.yaml\n")
|
|
195
|
+
end
|
|
196
|
+
end
|
|
172
197
|
|
|
173
198
|
describe "stage validation" do
|
|
174
199
|
it "accepts valid stage values" do
|