commenter 0.3.0 → 0.3.1
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/README.adoc +35 -6
- data/lib/commenter/cli.rb +10 -18
- data/lib/commenter/comment.rb +2 -10
- data/lib/commenter/comment_sheet.rb +8 -0
- data/lib/commenter/comment_type.rb +35 -0
- data/lib/commenter/github_integration.rb +11 -60
- data/lib/commenter/github_session.rb +35 -0
- data/lib/commenter/parser/osd_xlsx_parser.rb +1 -10
- data/lib/commenter/parser/track_change_docx_parser.rb +332 -0
- data/lib/commenter/parser.rb +8 -10
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +5 -0
- data/spec/commenter/comment_sheet_spec.rb +25 -0
- data/spec/commenter/comment_type_spec.rb +45 -0
- data/spec/commenter/github_integration_spec.rb +65 -32
- data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
- data/spec/support/redline_docx_builder.rb +88 -0
- metadata +8 -2
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
require_relative "../comment"
|
|
6
|
+
require_relative "../comment_sheet"
|
|
7
|
+
|
|
8
|
+
module Commenter
|
|
9
|
+
class Parser
|
|
10
|
+
# Extracts tracked changes from a redlined Word document into an ISO
|
|
11
|
+
# 2012-03 comment sheet.
|
|
12
|
+
#
|
|
13
|
+
# Captured track changes (w:ins, w:del, w:moveFrom, w:moveTo):
|
|
14
|
+
# - proposed_change renders the change itself, e.g. +Insert: "text"+
|
|
15
|
+
# - locality.clause is resolved from the nearest preceding heading
|
|
16
|
+
# - locality.element is resolved from caption/inline references
|
|
17
|
+
# (Table N, Figure N, Formula (N), NOTE n) within the current clause
|
|
18
|
+
# - observations can be stamped via the observations or accept_all options
|
|
19
|
+
#
|
|
20
|
+
# Reviewer comment threads (word/comments.xml w:comment) are emitted
|
|
21
|
+
# after the track changes with -CNNN ids: the remark verbatim in
|
|
22
|
+
# comments, its instruction reworded as the proposed change, and empty
|
|
23
|
+
# observations for the owner to draft.
|
|
24
|
+
#
|
|
25
|
+
# Self-closing markers (paragraph-mark insertions inside rPr) carry no
|
|
26
|
+
# content and are skipped.
|
|
27
|
+
#
|
|
28
|
+
# word/document.xml is streamed with Nokogiri::XML::Reader because redline
|
|
29
|
+
# documents can exceed 100 MB.
|
|
30
|
+
class TrackChangeDocxParser
|
|
31
|
+
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
32
|
+
|
|
33
|
+
HEADING_STYLE = /\A(?:Heading[1-6]|h[2-5]annex\d*|ANNEX|BaseHeading)\z/i
|
|
34
|
+
KIND_COMMENT = { "ins" => "insertion", "del" => "deletion", "moveFrom" => "move (from)",
|
|
35
|
+
"moveTo" => "move (to)" }.freeze
|
|
36
|
+
KIND_LABEL = { "ins" => "Insert", "del" => "Delete", "moveFrom" => "Move from",
|
|
37
|
+
"moveTo" => "Move to" }.freeze
|
|
38
|
+
|
|
39
|
+
DEFAULT_BODY = "CS"
|
|
40
|
+
WHOLE_DOCUMENT = "_whole document"
|
|
41
|
+
ACCEPT_ALL = "Accepted. Tracked change accepted."
|
|
42
|
+
|
|
43
|
+
attr_reader :skipped_markers
|
|
44
|
+
|
|
45
|
+
def parse(path, options = {})
|
|
46
|
+
body = options[:body] || DEFAULT_BODY
|
|
47
|
+
changes = []
|
|
48
|
+
remarks = {}
|
|
49
|
+
anchors = {}
|
|
50
|
+
@skipped_markers = 0
|
|
51
|
+
|
|
52
|
+
Zip::File.open(path) do |zip|
|
|
53
|
+
entry = zip.glob("word/document.xml").first
|
|
54
|
+
raise Commenter::Error, "word/document.xml not found in #{path}" unless entry
|
|
55
|
+
|
|
56
|
+
remarks = read_remarks(zip)
|
|
57
|
+
changes = stream_changes(entry.get_input_stream, anchors)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
CommentSheet.new(
|
|
61
|
+
version: "2012-03",
|
|
62
|
+
date: sheet_date(changes),
|
|
63
|
+
document: options[:document],
|
|
64
|
+
stage: options[:stage],
|
|
65
|
+
comments: build_change_comments(changes, body, options) +
|
|
66
|
+
build_remark_comments(remarks, anchors, body, options)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Turns a reviewer's remark phrased as a request ("Please remove this
|
|
71
|
+
# NOTE...") into the corresponding proposed change ("Remove this
|
|
72
|
+
# NOTE...").
|
|
73
|
+
def self.reword_remark(text)
|
|
74
|
+
text.to_s.strip.sub(/\APlease\s+/i, "").sub(/\A[a-z]/, &:upcase)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def read_remarks(zip)
|
|
80
|
+
entry = zip.glob("word/comments.xml").first
|
|
81
|
+
return {} unless entry
|
|
82
|
+
|
|
83
|
+
document = Nokogiri::XML(entry.get_input_stream)
|
|
84
|
+
document.xpath("//w:comment", "w" => W_NS).each_with_object({}) do |node, remarks|
|
|
85
|
+
remarks[node["w:id"]] = {
|
|
86
|
+
author: node["w:author"],
|
|
87
|
+
date: node["w:date"],
|
|
88
|
+
text: node.xpath(".//w:t", "w" => W_NS).map(&:text).join
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def sheet_date(changes)
|
|
94
|
+
dates = changes.filter_map { |change| change[:date].to_s[/\A\d{4}-\d{2}-\d{2}/] }
|
|
95
|
+
dates.max
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_change_comments(changes, body, options)
|
|
99
|
+
changes.each_with_index.map do |change, index|
|
|
100
|
+
kind = change[:kind]
|
|
101
|
+
Comment.new(
|
|
102
|
+
id: format("%<body>s-%03<index>d", body: body, index: index + 1),
|
|
103
|
+
body: body,
|
|
104
|
+
locality: { clause: change[:clause], line_number: nil, element: change[:element] },
|
|
105
|
+
type: options[:type] || "te",
|
|
106
|
+
comments: "Track change (#{KIND_COMMENT.fetch(kind, kind)})",
|
|
107
|
+
proposed_change: "#{KIND_LABEL.fetch(kind, kind)}: \"#{change[:text].strip}\"",
|
|
108
|
+
observations: observations_for(options)
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_remark_comments(remarks, anchors, body, options)
|
|
114
|
+
remarks.keys.sort_by(&:to_i).each_with_index.map do |remark_id, index|
|
|
115
|
+
remark = remarks[remark_id]
|
|
116
|
+
anchor = anchors[remark_id] || {}
|
|
117
|
+
clause = anchor[:clause].to_s.empty? ? WHOLE_DOCUMENT : anchor[:clause]
|
|
118
|
+
Comment.new(
|
|
119
|
+
id: format("%<body>s-C%03<index>d", body: body, index: index + 1),
|
|
120
|
+
body: body,
|
|
121
|
+
locality: { clause: clause, line_number: nil, element: anchor[:element] },
|
|
122
|
+
type: options[:remark_type] || "ed",
|
|
123
|
+
comments: remark[:text],
|
|
124
|
+
proposed_change: self.class.reword_remark(remark[:text]),
|
|
125
|
+
observations: nil
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def observations_for(options)
|
|
131
|
+
return nil if options[:exclude_observations]
|
|
132
|
+
|
|
133
|
+
options[:observations] || (options[:accept_all] ? ACCEPT_ALL : nil)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def stream_changes(io, anchors = {})
|
|
137
|
+
changes = []
|
|
138
|
+
change_stack = []
|
|
139
|
+
paragraph = ParagraphState.new
|
|
140
|
+
|
|
141
|
+
Nokogiri::XML::Reader(io).each do |reader|
|
|
142
|
+
case reader.node_type
|
|
143
|
+
when Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
144
|
+
handle_start(reader, change_stack, paragraph, anchors)
|
|
145
|
+
when Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE
|
|
146
|
+
handle_text(reader, change_stack, paragraph)
|
|
147
|
+
when Nokogiri::XML::Reader::TYPE_END_ELEMENT
|
|
148
|
+
handle_end(reader, change_stack, paragraph, changes)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
changes
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def handle_start(reader, change_stack, paragraph, anchors = {})
|
|
156
|
+
case reader.local_name
|
|
157
|
+
when "p"
|
|
158
|
+
paragraph.begin_paragraph
|
|
159
|
+
when "pStyle"
|
|
160
|
+
paragraph.style = reader.attribute("w:val") || reader.attribute("val")
|
|
161
|
+
when "ins", "del", "moveFrom", "moveTo"
|
|
162
|
+
start_change(reader, change_stack, paragraph)
|
|
163
|
+
when "commentRangeStart"
|
|
164
|
+
anchor_remark(reader, paragraph, anchors)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def anchor_remark(reader, paragraph, anchors)
|
|
169
|
+
id = reader.attribute("w:id")
|
|
170
|
+
anchors[id] = {
|
|
171
|
+
clause: paragraph.pending_clause || paragraph.clause,
|
|
172
|
+
element: paragraph.pending_element || paragraph.element
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def start_change(reader, change_stack, paragraph)
|
|
177
|
+
if reader.empty_element?
|
|
178
|
+
@skipped_markers += 1
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
change_stack.push(
|
|
183
|
+
kind: reader.local_name,
|
|
184
|
+
id: reader.attribute("w:id"),
|
|
185
|
+
author: reader.attribute("w:author"),
|
|
186
|
+
date: reader.attribute("w:date"),
|
|
187
|
+
text: +"",
|
|
188
|
+
clause: paragraph.clause,
|
|
189
|
+
element: paragraph.element
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def handle_text(reader, change_stack, paragraph)
|
|
194
|
+
if (current = change_stack.last)
|
|
195
|
+
current[:text] << reader.value
|
|
196
|
+
else
|
|
197
|
+
paragraph.append_text(reader.value)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def handle_end(reader, change_stack, paragraph, changes)
|
|
202
|
+
case reader.local_name
|
|
203
|
+
when "p"
|
|
204
|
+
paragraph.commit_paragraph
|
|
205
|
+
when "ins", "del", "moveFrom", "moveTo"
|
|
206
|
+
change = change_stack.pop
|
|
207
|
+
changes << finalize_change(change, paragraph) if change
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def finalize_change(change, paragraph)
|
|
212
|
+
# A change inside a heading or caption paragraph belongs to that
|
|
213
|
+
# paragraph, which is not committed yet at this point.
|
|
214
|
+
change[:clause] = paragraph.pending_clause || change[:clause]
|
|
215
|
+
change[:element] = paragraph.pending_element || change[:element]
|
|
216
|
+
change[:clause] = WHOLE_DOCUMENT if change[:clause].to_s.empty?
|
|
217
|
+
change
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Tracks the current paragraph's style and text to resolve the locality
|
|
221
|
+
# context of a change:
|
|
222
|
+
#
|
|
223
|
+
# - clause: the clause number of the most recent heading paragraph; an
|
|
224
|
+
# unnumbered sub-heading inherits its parent heading's clause
|
|
225
|
+
# - element: the nearest Table/Figure/Formula/NOTE reference, scoped to
|
|
226
|
+
# the current clause (caption paragraphs and inline mentions both
|
|
227
|
+
# count)
|
|
228
|
+
class ParagraphState
|
|
229
|
+
NUMBERED_CLAUSE = /\A(?:\d+(?:\.\d+)*|Annex\s+[A-Z](?:\.\d+)*|Bibliography|Foreword|Introduction)\z/i
|
|
230
|
+
ELEMENT_START = /\A\s*(NOTE\s+\d+|NOTE(?=\s*[—:-])|Table\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|Figure\s+(?:[A-Z]\.)?\d+(?:\.\d+)*)\b/i
|
|
231
|
+
FORMULA = /\b(Formula\s*\(\d+(?:\.\d+)*\))/
|
|
232
|
+
ELEMENT_ANY = /\b(Table\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|Figure\s+(?:[A-Z]\.)?\d+(?:\.\d+)*|NOTE\s+\d+)\b/i
|
|
233
|
+
|
|
234
|
+
attr_writer :style
|
|
235
|
+
attr_reader :clause, :element
|
|
236
|
+
|
|
237
|
+
def initialize
|
|
238
|
+
@style = nil
|
|
239
|
+
@text = +""
|
|
240
|
+
@clause = ""
|
|
241
|
+
@element = nil
|
|
242
|
+
@heading_stack = []
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def begin_paragraph
|
|
246
|
+
@style = nil
|
|
247
|
+
@text = +""
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def append_text(value)
|
|
251
|
+
@text << value
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def pending_clause
|
|
255
|
+
return unless heading_paragraph?
|
|
256
|
+
|
|
257
|
+
clause = self.class.clause_for(@text)
|
|
258
|
+
clause unless clause.empty?
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def pending_element
|
|
262
|
+
self.class.element_for(@text)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def commit_paragraph
|
|
266
|
+
if heading_paragraph?
|
|
267
|
+
commit_heading
|
|
268
|
+
else
|
|
269
|
+
@element = self.class.element_for(@text) || @element
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def heading_paragraph?
|
|
274
|
+
@style&.match?(HEADING_STYLE)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def self.numbered_clause?(clause)
|
|
278
|
+
clause.to_s.match?(NUMBERED_CLAUSE)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Extracts the clause identifier from heading text: the leading number
|
|
282
|
+
# ("4.2.1 Thermodynamic ..." -> "4.2.1"), an annex reference, or a
|
|
283
|
+
# well-known unnumbered section. Runs may be concatenated without
|
|
284
|
+
# spaces ("4.2.1Thermodynamic"), so the number is matched with a
|
|
285
|
+
# lookahead.
|
|
286
|
+
def self.clause_for(text)
|
|
287
|
+
s = text.to_s.strip
|
|
288
|
+
return "" if s.empty?
|
|
289
|
+
|
|
290
|
+
return Regexp.last_match(1) if s =~ /\A\s*(Annex\s+[A-Z](?:\.\d+)*)\b/i
|
|
291
|
+
return Regexp.last_match(1) if s =~ /\A\s*(Bibliography|Foreword|Introduction)\b/i
|
|
292
|
+
return Regexp.last_match(1) if s =~ /\A\s*((?:\d+\.)*\d+)(?=[A-Z\s])/
|
|
293
|
+
return Regexp.last_match(1) if s =~ /\A\s*((?:\d+\.)*\d+)\b/
|
|
294
|
+
|
|
295
|
+
s
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Resolves the element reference of a paragraph: a caption opening the
|
|
299
|
+
# paragraph ("Table 3 — ...", "NOTE 2 ...", "Formula (9):") wins over an
|
|
300
|
+
# inline mention ("the values in Table 5 shall ...").
|
|
301
|
+
def self.element_for(text)
|
|
302
|
+
s = text.to_s
|
|
303
|
+
match = s.match(ELEMENT_START) || s.match(FORMULA) || s.match(ELEMENT_ANY)
|
|
304
|
+
match[1].squeeze(" ").strip if match
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
def commit_heading
|
|
310
|
+
level = heading_level
|
|
311
|
+
clause = self.class.clause_for(@text)
|
|
312
|
+
clause = inherited_clause(level) unless self.class.numbered_clause?(clause)
|
|
313
|
+
@heading_stack.pop while @heading_stack.last && @heading_stack.last[0] >= level
|
|
314
|
+
@heading_stack << [level, clause]
|
|
315
|
+
@clause = clause unless clause.empty?
|
|
316
|
+
# Element references belong to the clause they appear in.
|
|
317
|
+
@element = nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def inherited_clause(level)
|
|
321
|
+
parent = @heading_stack.reverse.find { |(lvl, _)| lvl < level }
|
|
322
|
+
parent ? parent[1] : ""
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def heading_level
|
|
326
|
+
m = @style.match(/\A(?:Heading|h)(\d+)/i)
|
|
327
|
+
m ? m[1].to_i : 1
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
data/lib/commenter/parser.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "pathname"
|
|
|
5
5
|
require_relative "comment_sheet"
|
|
6
6
|
require_relative "comment"
|
|
7
7
|
require_relative "parser/osd_xlsx_parser"
|
|
8
|
+
require_relative "parser/track_change_docx_parser"
|
|
8
9
|
|
|
9
10
|
module Commenter
|
|
10
11
|
class Parser
|
|
@@ -16,6 +17,8 @@ module Commenter
|
|
|
16
17
|
parse_docx(input_path, options)
|
|
17
18
|
when :xlsx
|
|
18
19
|
parse_xlsx(input_path, options)
|
|
20
|
+
when :redline
|
|
21
|
+
parse_redline(input_path, options)
|
|
19
22
|
else
|
|
20
23
|
raise "Unsupported file format: #{input_path}. Supported formats: .docx, .xlsx"
|
|
21
24
|
end
|
|
@@ -38,6 +41,10 @@ module Commenter
|
|
|
38
41
|
OsdXlsxParser.new.parse(xlsx_path, options)
|
|
39
42
|
end
|
|
40
43
|
|
|
44
|
+
def parse_redline(docx_path, options)
|
|
45
|
+
TrackChangeDocxParser.new.parse(docx_path, options)
|
|
46
|
+
end
|
|
47
|
+
|
|
41
48
|
def parse_docx(docx_path, options)
|
|
42
49
|
doc = Docx::Document.open(docx_path)
|
|
43
50
|
|
|
@@ -83,7 +90,7 @@ module Commenter
|
|
|
83
90
|
clause: presence(cells[2]),
|
|
84
91
|
element: presence(cells[3])
|
|
85
92
|
},
|
|
86
|
-
type:
|
|
93
|
+
type: CommentType.code(cells[4]),
|
|
87
94
|
comments: cells[5] || "",
|
|
88
95
|
proposed_change: presence(cells[6]),
|
|
89
96
|
user_name: cells[0].to_s.strip
|
|
@@ -117,15 +124,6 @@ module Commenter
|
|
|
117
124
|
value && !value.empty? ? value : nil
|
|
118
125
|
end
|
|
119
126
|
|
|
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
127
|
def extract_metadata(doc)
|
|
130
128
|
metadata = { date: nil, document: nil, project: nil }
|
|
131
129
|
|
data/lib/commenter/version.rb
CHANGED
data/lib/commenter.rb
CHANGED
|
@@ -7,6 +7,11 @@ require_relative "commenter/parser"
|
|
|
7
7
|
require_relative "commenter/filler"
|
|
8
8
|
require_relative "commenter/github_integration"
|
|
9
9
|
|
|
10
|
+
# Commenter converts ISO comment sheets (DOCX/XLSX) to structured YAML and
|
|
11
|
+
# syncs comments to GitHub issues.
|
|
10
12
|
module Commenter
|
|
11
13
|
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
autoload :CommentType, "commenter/comment_type"
|
|
16
|
+
autoload :GitHubSession, "commenter/github_session"
|
|
12
17
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Commenter::CommentType do
|
|
6
|
+
describe ".code" do
|
|
7
|
+
it "maps full names and codes to codes" do
|
|
8
|
+
expect(described_class.code("Editorial")).to eq("ed")
|
|
9
|
+
expect(described_class.code("general")).to eq("ge")
|
|
10
|
+
expect(described_class.code("te")).to eq("te")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "passes unrecognized values through" do
|
|
14
|
+
expect(described_class.code("custom")).to eq("custom")
|
|
15
|
+
expect(described_class.code("")).to eq("")
|
|
16
|
+
expect(described_class.code(nil)).to be_nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
describe ".full_name" do
|
|
21
|
+
it "expands codes to full names" do
|
|
22
|
+
expect(described_class.full_name("ge")).to eq("general")
|
|
23
|
+
expect(described_class.full_name("TE")).to eq("technical")
|
|
24
|
+
expect(described_class.full_name("editorial")).to eq("editorial")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "passes unrecognized values through" do
|
|
28
|
+
expect(described_class.full_name("Custom")).to eq("Custom")
|
|
29
|
+
expect(described_class.full_name(nil)).to be_nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe ".display_name" do
|
|
34
|
+
it "capitalizes recognized types for display" do
|
|
35
|
+
expect(described_class.display_name("ge")).to eq("General")
|
|
36
|
+
expect(described_class.display_name("technical")).to eq("Technical")
|
|
37
|
+
expect(described_class.display_name("ed")).to eq("Editorial")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "defaults unknown or missing types" do
|
|
41
|
+
expect(described_class.display_name("unknown")).to eq("unknown")
|
|
42
|
+
expect(described_class.display_name(nil)).to eq("Unknown")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -135,49 +135,82 @@ RSpec.describe Commenter::GitHubIssueCreator do
|
|
|
135
135
|
|
|
136
136
|
describe "template variable generation" do
|
|
137
137
|
let(:creator) { described_class.new(config_file.path, title_template_file.path, body_template_file.path) }
|
|
138
|
-
let(:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
expect(variables
|
|
152
|
-
|
|
153
|
-
|
|
138
|
+
let(:title_template_file) do
|
|
139
|
+
file = Tempfile.new(["title", ".liquid"])
|
|
140
|
+
file.write("{{ stage }}|{{ document }}|{{ project }}|{{ version }}|{{ comment_id }}|{{ type }}|" \
|
|
141
|
+
"{{ type_full_name }}|{{ clause }}|{{ element }}|{{ line_number }}|" \
|
|
142
|
+
"{{ has_observations }}|{{ has_proposed_change }}|{{ locality_summary }}|{{ unique_id }}")
|
|
143
|
+
file.close
|
|
144
|
+
file
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "exposes sheet, comment, and computed variables to templates" do
|
|
148
|
+
result = creator.create_issues_from_yaml(yaml_file.path, dry_run: true).first
|
|
149
|
+
variables = result[:title].split("|")
|
|
150
|
+
|
|
151
|
+
expect(variables).to eq(
|
|
152
|
+
[
|
|
153
|
+
"DIS", "Test Document", "Test Project", "2012-03", "US-001", "technical",
|
|
154
|
+
"Technical", "5.1", "Table 1", "", "false", "true", "Clause 5.1, Table 1", "[DIS] US-001"
|
|
155
|
+
]
|
|
156
|
+
)
|
|
154
157
|
end
|
|
155
158
|
end
|
|
156
159
|
|
|
157
160
|
describe "label determination" do
|
|
158
161
|
let(:creator) { described_class.new(config_file.path, title_template_file.path, body_template_file.path) }
|
|
159
|
-
let(:comment_sheet) { Commenter::CommentSheet.from_hash(yaml_data) }
|
|
160
|
-
let(:comment) { comment_sheet.comments.first }
|
|
161
162
|
|
|
162
|
-
it "combines default, stage-specific, and comment type labels" do
|
|
163
|
-
|
|
163
|
+
it "combines default, stage-specific, and comment type labels without duplicates" do
|
|
164
|
+
result = creator.create_issues_from_yaml(yaml_file.path, dry_run: true).first
|
|
165
|
+
labels = result[:labels]
|
|
164
166
|
|
|
165
|
-
expect(labels).to include("comment-review"
|
|
166
|
-
expect(labels).to
|
|
167
|
-
expect(labels).to include("technical") # comment type (expanded)
|
|
168
|
-
expect(labels.uniq).to eq(labels) # no duplicates
|
|
167
|
+
expect(labels).to include("comment-review", "draft-international-standard", "technical")
|
|
168
|
+
expect(labels.uniq).to eq(labels)
|
|
169
169
|
end
|
|
170
170
|
end
|
|
171
|
+
end
|
|
171
172
|
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
RSpec.describe Commenter::GitHubIssueRetriever do
|
|
174
|
+
let(:config_file) do
|
|
175
|
+
file = Tempfile.new(["config", ".yaml"])
|
|
176
|
+
file.write({ "github" => { "repository" => "test-org/test-repo", "token" => "test-token" } }.to_yaml)
|
|
177
|
+
file.close
|
|
178
|
+
file
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
let(:osd_yaml_file) do
|
|
182
|
+
file = Tempfile.new(["comments", ".yaml"])
|
|
183
|
+
file.write({
|
|
184
|
+
"version" => "osd",
|
|
185
|
+
"document" => "ISO/DIS 5843-6(en)",
|
|
186
|
+
"stage" => "DIS",
|
|
187
|
+
"comments" => [
|
|
188
|
+
{
|
|
189
|
+
"id" => "1",
|
|
190
|
+
"body" => "John Doe",
|
|
191
|
+
"locality" => { "clause" => "5.2.1" },
|
|
192
|
+
"type" => "editorial",
|
|
193
|
+
"comments" => "The values in Table 3 are inconsistent."
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
}.to_yaml)
|
|
197
|
+
file.close
|
|
198
|
+
file
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
after do
|
|
202
|
+
config_file.unlink
|
|
203
|
+
osd_yaml_file.unlink
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
describe "#retrieve_observations_from_yaml" do
|
|
207
|
+
it "rewrites the YAML with the schema header matching its version" do
|
|
208
|
+
retriever = described_class.new(config_file.path)
|
|
209
|
+
|
|
210
|
+
retriever.retrieve_observations_from_yaml(osd_yaml_file.path)
|
|
174
211
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
expect(creator.send(:expand_comment_type, "te")).to eq("Technical")
|
|
178
|
-
expect(creator.send(:expand_comment_type, "ed")).to eq("Editorial")
|
|
179
|
-
expect(creator.send(:expand_comment_type, "unknown")).to eq("unknown")
|
|
180
|
-
expect(creator.send(:expand_comment_type, nil)).to eq("Unknown")
|
|
212
|
+
expect(osd_yaml_file.open.read.lines.first)
|
|
213
|
+
.to eq("# yaml-language-server: $schema=schema/iso_comment_osd.yaml\n")
|
|
181
214
|
end
|
|
182
215
|
end
|
|
183
216
|
end
|