commenter 0.2.3 → 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.
@@ -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
@@ -1,12 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "docx"
4
+ require "pathname"
4
5
  require_relative "comment_sheet"
5
6
  require_relative "comment"
7
+ require_relative "parser/osd_xlsx_parser"
8
+ require_relative "parser/track_change_docx_parser"
6
9
 
7
10
  module Commenter
8
11
  class Parser
9
- def parse(docx_path, options = {})
12
+ def parse(input_path, options = {})
13
+ format = detect_format(input_path, options)
14
+
15
+ case format
16
+ when :docx
17
+ parse_docx(input_path, options)
18
+ when :xlsx
19
+ parse_xlsx(input_path, options)
20
+ when :redline
21
+ parse_redline(input_path, options)
22
+ else
23
+ raise "Unsupported file format: #{input_path}. Supported formats: .docx, .xlsx"
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def detect_format(path, options = {})
30
+ return options[:format].to_sym if options[:format]
31
+
32
+ ext = File.extname(path).downcase
33
+ case ext
34
+ when ".docx" then :docx
35
+ when ".xlsx", ".xls" then :xlsx
36
+ else :unknown
37
+ end
38
+ end
39
+
40
+ def parse_xlsx(xlsx_path, options)
41
+ OsdXlsxParser.new.parse(xlsx_path, options)
42
+ end
43
+
44
+ def parse_redline(docx_path, options)
45
+ TrackChangeDocxParser.new.parse(docx_path, options)
46
+ end
47
+
48
+ def parse_docx(docx_path, options)
10
49
  doc = Docx::Document.open(docx_path)
11
50
 
12
51
  # Extract metadata from the first table
@@ -17,41 +56,13 @@ module Commenter
17
56
  raise "No comments table found in document" unless comments_table
18
57
  raise "Comments table appears to be empty" if comments_table.row_count < 2
19
58
 
20
- comments = []
21
-
22
59
  # Process all rows - don't skip any rows, respect all content
23
- (0..comments_table.row_count - 1).each do |i|
24
- row = comments_table.rows[i]
25
- cells = row.cells.map { |c| c.text.strip }
26
-
27
- # Skip only completely empty rows
60
+ comments = (0..comments_table.row_count - 1).map do |row_index|
61
+ cells = comments_table.rows[row_index].cells.map { |cell| cell.text.strip }
28
62
  next if cells.all?(&:empty?)
29
63
 
30
- # Extract body from ID (e.g., "DE-001" -> "DE")
31
- id = cells[0] || ""
32
- body = id.include?("-") ? id.split("-").first : id
33
-
34
- # Create comment with symbol keys, respecting all input data
35
- comment_attrs = {
36
- id: id,
37
- body: body,
38
- locality: {
39
- line_number: cells[1] && cells[1].empty? ? nil : cells[1],
40
- clause: cells[2] && cells[2].empty? ? nil : cells[2],
41
- element: cells[3] && cells[3].empty? ? nil : cells[3]
42
- },
43
- type: cells[4] || "",
44
- comments: cells[5] || "",
45
- proposed_change: cells[6] || ""
46
- }
47
-
48
- # Handle observations column
49
- unless options[:exclude_observations]
50
- comment_attrs[:observations] = cells[7] && cells[7].empty? ? nil : cells[7]
51
- end
52
-
53
- comments << Comment.new(comment_attrs)
54
- end
64
+ osd_docx_row?(cells) ? build_osd_docx_comment(cells, options) : build_classic_docx_comment(cells, options)
65
+ end.compact
55
66
 
56
67
  # Create comment sheet
57
68
  CommentSheet.new(
@@ -63,7 +74,55 @@ module Commenter
63
74
  )
64
75
  end
65
76
 
66
- private
77
+ # The DOCX from ISO OSD has 9 columns:
78
+ # 0: User name, 1: (empty/line), 2: Clause nb, 3: Clause Title,
79
+ # 4: Type, 5: Comment, 6: (empty/proposal), 7: Observations, 8: Comment number
80
+ # Detected by checking if last column looks like a numeric ID.
81
+ def osd_docx_row?(cells)
82
+ cells.length >= 9 && cells[8].to_s.match?(/^\d+$/)
83
+ end
84
+
85
+ def build_osd_docx_comment(cells, options)
86
+ attrs = {
87
+ id: cells[8],
88
+ body: cells[0].to_s.strip,
89
+ locality: {
90
+ clause: presence(cells[2]),
91
+ element: presence(cells[3])
92
+ },
93
+ type: CommentType.code(cells[4]),
94
+ comments: cells[5] || "",
95
+ proposed_change: presence(cells[6]),
96
+ user_name: cells[0].to_s.strip
97
+ }
98
+ attrs[:observations] = presence(cells[7]) unless options[:exclude_observations]
99
+ Comment.new(attrs)
100
+ end
101
+
102
+ # Classic ISO comment template format (8 columns):
103
+ # 0: ID, 1: line_number, 2: clause, 3: element, 4: type, 5: comments,
104
+ # 6: proposed_change, 7: observations
105
+ def build_classic_docx_comment(cells, options)
106
+ id = cells[0] || ""
107
+ attrs = {
108
+ id: id,
109
+ body: id.include?("-") ? id.split("-").first : id,
110
+ locality: {
111
+ line_number: presence(cells[1]),
112
+ clause: presence(cells[2]),
113
+ element: presence(cells[3])
114
+ },
115
+ type: cells[4] || "",
116
+ comments: cells[5] || "",
117
+ proposed_change: cells[6] || ""
118
+ }
119
+ attrs[:observations] = presence(cells[7]) unless options[:exclude_observations]
120
+ Comment.new(attrs)
121
+ end
122
+
123
+ def presence(value)
124
+ value && !value.empty? ? value : nil
125
+ end
67
126
 
68
127
  def extract_metadata(doc)
69
128
  metadata = { date: nil, document: nil, project: nil }
@@ -96,9 +155,6 @@ module Commenter
96
155
  project_match = all_text.match(/Project:\s*([^\n\r]+)/)
97
156
  metadata[:project] = project_match[1]&.strip if project_match
98
157
 
99
- # If no metadata found, try to extract from filename or other sources
100
- # This is a fallback - in practice, users might need to provide metadata manually
101
-
102
158
  metadata
103
159
  end
104
160
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commenter
4
- VERSION = "0.2.3"
4
+ VERSION = "0.3.1"
5
5
  end
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
@@ -68,8 +68,8 @@ properties:
68
68
  required: ["clause"]
69
69
  type:
70
70
  type: string
71
- enum: ["ge", "te", "ed"]
72
- description: "Type of comment: ge=general, te=technical, ed=editorial"
71
+ enum: ["ge", "te", "ed", "general", "technical", "editorial"]
72
+ description: "Type of comment: ge=general, te=technical, ed=editorial. Codes are expanded to full names in output"
73
73
  comments:
74
74
  type: string
75
75
  description: "The actual comment text"
@@ -0,0 +1,112 @@
1
+ # ISO Comment Schema for ISO Online Standards Development (OSD) format
2
+ # Supports both resolved and unresolved XLSX exports from ISO OSD
3
+ $schema: http://json-schema.org/draft-07/schema#
4
+ title: ISO Comment OSD
5
+ description: Schema for ISO comments exported from ISO Online Standards Development (OSD) platform
6
+ type: object
7
+ properties:
8
+ version:
9
+ type: string
10
+ const: "osd"
11
+ description: Version identifier for OSD format
12
+ date:
13
+ type: ["string", "null"]
14
+ description: Date from the XLSX header
15
+ format: date
16
+ document:
17
+ type: ["string", "null"]
18
+ description: Document reference (e.g. ISO/DIS 5843-6(en))
19
+ project:
20
+ type: ["string", "null"]
21
+ description: Project name
22
+ title_en:
23
+ type: ["string", "null"]
24
+ description: Document title in English
25
+ title_fr:
26
+ type: ["string", "null"]
27
+ description: Document title in French
28
+ stage:
29
+ type: ["string", "null"]
30
+ description: Approval stage (WD/CD/DIS/FDIS/PRF/PUB)
31
+ enum: [null, "WD", "CD", "DIS", "FDIS", "PRF", "PUB"]
32
+ comments:
33
+ type: array
34
+ description: Array of comment entries from the ISO OSD export
35
+ items:
36
+ type: object
37
+ properties:
38
+ id:
39
+ type: ["string", "number"]
40
+ description: "Comment ID (numeric from OSD)"
41
+ body:
42
+ type: ["string", "null"]
43
+ description: "Member body or user name"
44
+ locality:
45
+ type: object
46
+ description: "Location information for the comment"
47
+ properties:
48
+ line_number:
49
+ type: ["string", "null"]
50
+ clause:
51
+ type: ["string", "null"]
52
+ element:
53
+ type: ["string", "null"]
54
+ description: "Clause Title from OSD"
55
+ type:
56
+ type: ["string", "null"]
57
+ description: "Type of comment; codes (ge/te/ed) are expanded to general/technical/editorial in output"
58
+ enum: [null, "ge", "te", "ed", "general", "technical", "editorial"]
59
+ comments:
60
+ type: ["string", "null"]
61
+ description: "The comment text"
62
+ proposed_change:
63
+ type: ["string", "null"]
64
+ description: "Proposed change text"
65
+ observations:
66
+ type: ["string", "null"]
67
+ description: "Resolution status or observations"
68
+ user_name:
69
+ type: ["string", "null"]
70
+ description: "User name from OSD"
71
+ comment_type:
72
+ type: ["string", "null"]
73
+ description: "Comment type/subtype from OSD (e.g. Editorial, General, Technical)"
74
+ resolution_status:
75
+ type: ["string", "null"]
76
+ description: "Resolution status (Accepted, Partially accepted, Rejected, etc.)"
77
+ resolution_date:
78
+ type: ["string", "null"]
79
+ description: "Date when the comment was resolved"
80
+ feedbacks:
81
+ type: ["string", "null"]
82
+ description: "Feedback/replies from the discussion"
83
+ motivation:
84
+ type: ["string", "null"]
85
+ description: "Motivation/justification for resolution"
86
+ created_date:
87
+ type: ["string", "null"]
88
+ description: "Date when the comment was created"
89
+ stage_code:
90
+ type: ["string", "null"]
91
+ description: "ISO stage code (e.g. 40.20)"
92
+ github:
93
+ type: ["object", "null"]
94
+ description: "GitHub integration information"
95
+ properties:
96
+ issue_number:
97
+ type: integer
98
+ issue_url:
99
+ type: string
100
+ format: uri
101
+ status:
102
+ type: string
103
+ enum: ["open", "closed"]
104
+ created_at:
105
+ type: string
106
+ format: date-time
107
+ updated_at:
108
+ type: string
109
+ format: date-time
110
+ required: ["issue_number", "issue_url", "status"]
111
+ required: ["id", "locality", "comments"]
112
+ required: ["version", "comments"]
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "commenter/cli"
5
+ require_relative "../support/osd_fixtures"
6
+ require_relative "../support/xlsx_builder"
7
+
8
+ RSpec.describe Commenter::Cli do
9
+ include OsdFixtures
10
+
11
+ describe "#import" do
12
+ it "writes OSD YAML and copies the OSD schema for XLSX input" do
13
+ Dir.mktmpdir do |dir|
14
+ xlsx = File.join(dir, "comments.xlsx")
15
+ rows = header_block(OsdFixtures::RESOLVED_HEADERS) + [
16
+ [1, "John Doe", "5.2.1", "Requirements", "Comment", "Editorial",
17
+ "The values in Table 3 are inconsistent.", nil, "Correct the values.", nil, nil, nil,
18
+ "2026-04-01", "Accepted", nil, "2026-04-10", "40.20"]
19
+ ]
20
+ XlsxBuilder.write(xlsx, [["Comments (1)", rows]])
21
+ output = File.join(dir, "out", "comments.yaml")
22
+ schema_dir = File.join(dir, "out", "schema")
23
+
24
+ described_class.start(["import", xlsx, "--output", output, "--schema-dir", schema_dir])
25
+
26
+ data = YAML.safe_load_file(output)
27
+ expect(data["version"]).to eq("osd")
28
+ expect(data["comments"].length).to eq(1)
29
+ expect(data["comments"].first["id"]).to eq("1")
30
+ expect(File.read(output)).to start_with("# yaml-language-server: $schema=")
31
+ expect(File).to exist(File.join(schema_dir, "iso_comment_osd.yaml"))
32
+ end
33
+ end
34
+ end
35
+ end