commenter 0.3.1 → 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.
@@ -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
@@ -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[: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)
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 "includes nil stage when not set" do
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).to have_key(:stage)
132
- expect(hash[:stage]).to be_nil
131
+ expect(hash).not_to have_key("stage")
132
+ expect(sheet.version).to eq("2012-03")
133
133
  end
134
134
  end
135
135
 
@@ -9,7 +9,7 @@ RSpec.describe Commenter::Comment do
9
9
  id: "US-001",
10
10
  body: "US",
11
11
  locality: { clause: "5.1", element: "Table 1", line_number: "42" },
12
- type: "te",
12
+ type: "technical",
13
13
  comments: "Test comment",
14
14
  proposed_change: "Test change",
15
15
  observations: "Test observations"
@@ -28,6 +28,19 @@ RSpec.describe Commenter::Comment do
28
28
  expect(comment.observations).to eq("Test observations")
29
29
  end
30
30
 
31
+ it "expands short type codes when loaded from YAML" do
32
+ yaml = <<~YAML
33
+ ---
34
+ id: US-009
35
+ type: te
36
+ YAML
37
+
38
+ comment = described_class.from_yaml(yaml)
39
+
40
+ expect(comment.id).to eq("US-009")
41
+ expect(comment.type).to eq("technical")
42
+ end
43
+
31
44
  it "handles string keys in attributes" do
32
45
  attributes = {
33
46
  "id" => "US-001",
@@ -162,7 +175,7 @@ RSpec.describe Commenter::Comment do
162
175
  id: "US-001",
163
176
  body: "US",
164
177
  locality: { clause: "5.1" },
165
- type: "te",
178
+ type: "technical",
166
179
  comments: "Test",
167
180
  proposed_change: "Change",
168
181
  observations: "Obs"
@@ -170,13 +183,13 @@ RSpec.describe Commenter::Comment do
170
183
 
171
184
  hash = comment.to_h
172
185
 
173
- expect(hash[:id]).to eq("US-001")
174
- expect(hash[:body]).to eq("US")
175
- expect(hash[:locality][:clause]).to eq("5.1")
176
- expect(hash[:type]).to eq("technical")
177
- expect(hash[:comments]).to eq("Test")
178
- expect(hash[:proposed_change]).to eq("Change")
179
- expect(hash[:observations]).to eq("Obs")
186
+ expect(hash["id"]).to eq("US-001")
187
+ expect(hash["body"]).to eq("US")
188
+ expect(hash["locality"]["clause"]).to eq("5.1")
189
+ expect(hash["type"]).to eq("technical")
190
+ expect(hash["comments"]).to eq("Test")
191
+ expect(hash["proposed_change"]).to eq("Change")
192
+ expect(hash["observations"]).to eq("Obs")
180
193
  end
181
194
  end
182
195
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Commenter::DispositionStatus do
6
+ describe ".match" do
7
+ it "classifies accepted dispositions" do
8
+ expect(described_class.match("Accepted")).to eq(:accepted)
9
+ expect(described_class.match("accepted.")).to eq(:accepted)
10
+ expect(described_class.match("Partially accepted")).to eq(:accepted)
11
+ end
12
+
13
+ it "classifies accept-with-modifications before the bare accept pattern" do
14
+ expect(described_class.match("Accept with modifications")).to eq(:accept_with_modifications)
15
+ expect(described_class.match("Accepted with modifications")).to eq(:accept_with_modifications)
16
+ expect(described_class.match("Accepted, with changes")).to eq(:accept_with_modifications)
17
+ expect(described_class.match("AWM")).to eq(:accept_with_modifications)
18
+ end
19
+
20
+ it "classifies rejection including the 'not accepted' phrasing" do
21
+ expect(described_class.match("Rejected")).to eq(:rejected)
22
+ expect(described_class.match("Not accepted")).to eq(:rejected)
23
+ end
24
+
25
+ it "classifies noted, todo, and non-matching text" do
26
+ expect(described_class.match("Noted")).to eq(:noted)
27
+ expect(described_class.match("TODO: review")).to eq(:todo)
28
+ expect(described_class.match("Deferred to next revision")).to be_nil
29
+ expect(described_class.match("")).to be_nil
30
+ expect(described_class.match(nil)).to be_nil
31
+ end
32
+ end
33
+
34
+ describe ".statuses" do
35
+ it "lists the canonical statuses" do
36
+ expect(described_class.statuses)
37
+ .to eq(%i[accept_with_modifications rejected accepted noted todo])
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "nokogiri"
5
+ require "zip"
6
+
7
+ RSpec.describe Commenter::Filler do
8
+ let(:template_path) { File.expand_path("../../data/iso_comment_template_2012-03.docx", __dir__) }
9
+
10
+ def comment(attrs)
11
+ Commenter::Comment.new({ body: "US", type: "technical", comments: "Text" }.merge(attrs))
12
+ end
13
+
14
+ def header_xml(output_path)
15
+ Zip::File.open(output_path) do |zip|
16
+ zip.entries.find { |entry| entry.name == "word/header2.xml" }.get_input_stream(&:read)
17
+ end
18
+ end
19
+
20
+ def body_xml(output_path)
21
+ Zip::File.open(output_path) do |zip|
22
+ zip.entries.find { |entry| entry.name == "word/document.xml" }.get_input_stream(&:read)
23
+ end
24
+ end
25
+
26
+ # Rows of the comment table as arrays of cell text. Read via Nokogiri over
27
+ # the extracted XML rather than Docx::Document, whose open zip handle keeps
28
+ # the file locked on Windows and breaks Dir.mktmpdir teardown.
29
+ def body_rows(output_path)
30
+ document = Nokogiri::XML(body_xml(output_path))
31
+ document.xpath("//w:tbl").flat_map { |table| table.xpath("./w:tr") }.map do |row|
32
+ row.xpath("./w:tc").map { |cell| cell.xpath(".//w:t").map(&:text).join }
33
+ end
34
+ end
35
+
36
+ it "writes comment rows into the template table" do
37
+ Dir.mktmpdir do |dir|
38
+ output = File.join(dir, "filled.docx")
39
+ described_class.new.fill(template_path, output, [
40
+ comment(id: "US-001", locality: { clause: "5.2.1", element: "Table 3", line_number: "45" },
41
+ proposed_change: "Fix them", observations: "Accepted")
42
+ ])
43
+
44
+ row = body_rows(output).first
45
+ expect(row[0]).to include("US-001")
46
+ expect(row[1]).to include("45")
47
+ expect(row[2]).to include("5.2.1")
48
+ expect(row[3]).to include("Table 3")
49
+ expect(row[4]).to include("technical")
50
+ expect(row[5]).to include("Text")
51
+ expect(row[6]).to include("Fix them")
52
+ expect(row[7]).to include("Accepted")
53
+ end
54
+ end
55
+
56
+ it "keeps multiple comments in input order and accepts plain hashes" do
57
+ Dir.mktmpdir do |dir|
58
+ output = File.join(dir, "filled.docx")
59
+ described_class.new.fill(template_path, output, [
60
+ { "id" => "DE-001", "comments" => "First" },
61
+ comment(id: "US-001", comments: "Second")
62
+ ])
63
+
64
+ rows = body_rows(output)
65
+ expect(rows.length).to eq(2)
66
+ expect(rows[0][0]).to include("DE-001")
67
+ expect(rows[1][0]).to include("US-001")
68
+ end
69
+ end
70
+
71
+ it "fills the header metadata labels in the page header" do
72
+ Dir.mktmpdir do |dir|
73
+ output = File.join(dir, "filled.docx")
74
+ described_class.new.fill(template_path, output, [comment(id: "US-001")],
75
+ date: "2026-08-28", document: "ISO 2533:2026", project: "Ballot review")
76
+
77
+ header = header_xml(output)
78
+ expect(header).to include("Date: 2026-08-28")
79
+ expect(header).to include("Document: ISO 2533:2026")
80
+ expect(header).to include("Project: Ballot review")
81
+ end
82
+ end
83
+
84
+ it "leaves header labels untouched when no metadata is given" do
85
+ Dir.mktmpdir do |dir|
86
+ output = File.join(dir, "filled.docx")
87
+ described_class.new.fill(template_path, output, [comment(id: "US-001")])
88
+
89
+ header = header_xml(output)
90
+ expect(header).to include(">Date: <")
91
+ expect(header).to include(">Document:<")
92
+ expect(header).to include(">Project:<")
93
+ end
94
+ end
95
+
96
+ it "escapes XML special characters in metadata values" do
97
+ Dir.mktmpdir do |dir|
98
+ output = File.join(dir, "filled.docx")
99
+ described_class.new.fill(template_path, output, [comment(id: "US-001")],
100
+ document: "A&B <standard>")
101
+
102
+ expect(header_xml(output)).to include("Document: A&amp;B &lt;standard&gt;")
103
+ end
104
+ end
105
+
106
+ it "shades observation cells according to disposition status" do
107
+ Dir.mktmpdir do |dir|
108
+ output = File.join(dir, "filled.docx")
109
+ described_class.new.fill(template_path, output, [
110
+ comment(id: "US-001", observations: "Accepted"),
111
+ comment(id: "US-002", observations: "Not accepted")
112
+ ], shading: true)
113
+
114
+ xml = body_xml(output)
115
+ expect(xml).to include('w:fill="92D050"')
116
+ expect(xml).to include('w:fill="FF99CC"')
117
+ end
118
+ end
119
+ end
@@ -27,7 +27,8 @@ RSpec.describe Commenter::Parser::OsdXlsxParser do
27
27
 
28
28
  expect(comment.id).to eq("1")
29
29
  expect(comment.body).to eq("John Doe")
30
- expect(comment.locality).to eq({ clause: "5.2.1", element: "Requirements" })
30
+ expect(comment.locality.clause).to eq("5.2.1")
31
+ expect(comment.locality.element).to eq("Requirements")
31
32
  expect(comment.comments).to eq("The values in Table 3 are inconsistent.")
32
33
  expect(comment.proposed_change).to eq("Correct the values in column 2.")
33
34
  expect(comment.comment_type).to eq("Editorial")
@@ -62,7 +63,7 @@ RSpec.describe Commenter::Parser::OsdXlsxParser do
62
63
  comments = described_class.new.parse(path).comments
63
64
 
64
65
  expect(comments.length).to eq(2)
65
- expect(comments.last.locality).to eq({})
66
+ expect(comments.last.locality.clause).to be_nil
66
67
  expect(comments.last.proposed_change).to be_nil
67
68
  end
68
69
  end
@@ -25,10 +25,17 @@ module RedlineDocxBuilder
25
25
  private_constant :HEADING_LEVEL_STYLE, :CHANGE_TAG
26
26
 
27
27
  def self.write(path, paragraphs, comments: [])
28
- zip = Zip::File.new(path, create: true)
29
- zip.get_output_stream("word/document.xml") { |stream| stream.write(document_xml(paragraphs)) }
30
- zip.get_output_stream("word/comments.xml") { |stream| stream.write(comments_xml(comments)) }
31
- zip.close
28
+ # Written via an in-memory buffer and File.binwrite rather than
29
+ # Zip::File#create + close: rubyzip commits the latter with File.rename,
30
+ # which fails with EACCES on Windows when the caller still holds an open
31
+ # handle on the destination (e.g. an unclosed Tempfile).
32
+ buffer = Zip::OutputStream.write_buffer do |out|
33
+ out.put_next_entry("word/document.xml")
34
+ out.write(document_xml(paragraphs))
35
+ out.put_next_entry("word/comments.xml")
36
+ out.write(comments_xml(comments))
37
+ end
38
+ File.binwrite(path, buffer.string)
32
39
  path
33
40
  end
34
41
 
@@ -18,22 +18,27 @@ module XlsxBuilder
18
18
  shared = []
19
19
  worksheets = sheets.map { |name, rows| [name, worksheet_xml(rows, shared)] }
20
20
 
21
- zip = Zip::File.new(path, create: true)
22
- write_entry(zip, "[Content_Types].xml", content_types_xml(worksheets.size))
23
- write_entry(zip, "_rels/.rels", root_rels_xml)
24
- write_entry(zip, "xl/workbook.xml", workbook_xml(worksheets.map(&:first)))
25
- write_entry(zip, "xl/_rels/workbook.xml.rels", workbook_rels_xml(worksheets.size))
26
- write_entry(zip, "xl/sharedStrings.xml", shared_strings_xml(shared))
27
- write_entry(zip, "xl/styles.xml", styles_xml)
28
- worksheets.each_with_index do |(_name, xml), index|
29
- write_entry(zip, "xl/worksheets/sheet#{index + 1}.xml", xml)
21
+ # In-memory buffer + File.binwrite rather than Zip::File#create + close:
22
+ # rubyzip commits the latter with File.rename, which fails with EACCES on
23
+ # Windows when the caller still holds an open handle on the destination.
24
+ buffer = Zip::OutputStream.write_buffer do |out|
25
+ write_entry(out, "[Content_Types].xml", content_types_xml(worksheets.size))
26
+ write_entry(out, "_rels/.rels", root_rels_xml)
27
+ write_entry(out, "xl/workbook.xml", workbook_xml(worksheets.map(&:first)))
28
+ write_entry(out, "xl/_rels/workbook.xml.rels", workbook_rels_xml(worksheets.size))
29
+ write_entry(out, "xl/sharedStrings.xml", shared_strings_xml(shared))
30
+ write_entry(out, "xl/styles.xml", styles_xml)
31
+ worksheets.each_with_index do |(_name, xml), index|
32
+ write_entry(out, "xl/worksheets/sheet#{index + 1}.xml", xml)
33
+ end
30
34
  end
31
- zip.close
35
+ File.binwrite(path, buffer.string)
32
36
  path
33
37
  end
34
38
 
35
- def self.write_entry(zip, name, content)
36
- zip.get_output_stream(name) { |stream| stream.write(content) }
39
+ def self.write_entry(out, name, content)
40
+ out.put_next_entry(name)
41
+ out.write(content)
37
42
  end
38
43
 
39
44
  def self.worksheet_xml(rows, shared)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commenter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-27 00:00:00.000000000 Z
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '5.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: lutaml-model
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.8'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.8'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: octokit
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -125,6 +139,7 @@ files:
125
139
  - ".rubocop_todo.yml"
126
140
  - CLAUDE.md
127
141
  - CODE_OF_CONDUCT.md
142
+ - CONTEXT.md
128
143
  - Gemfile
129
144
  - README.adoc
130
145
  - Rakefile
@@ -136,11 +151,16 @@ files:
136
151
  - data/iso_comment_template_2012-03.docx
137
152
  - exe/commenter
138
153
  - lib/commenter.rb
154
+ - lib/commenter/ballot.rb
155
+ - lib/commenter/ballot_report.rb
139
156
  - lib/commenter/cli.rb
140
157
  - lib/commenter/comment.rb
158
+ - lib/commenter/comment_locality.rb
141
159
  - lib/commenter/comment_sheet.rb
142
160
  - lib/commenter/comment_type.rb
161
+ - lib/commenter/disposition_status.rb
143
162
  - lib/commenter/filler.rb
163
+ - lib/commenter/github_info.rb
144
164
  - lib/commenter/github_integration.rb
145
165
  - lib/commenter/github_session.rb
146
166
  - lib/commenter/parser.rb
@@ -150,10 +170,14 @@ files:
150
170
  - schema/iso_comment_2012-03.yaml
151
171
  - schema/iso_comment_osd.yaml
152
172
  - sig/commenter.rbs
173
+ - spec/commenter/ballot_report_spec.rb
174
+ - spec/commenter/ballot_spec.rb
153
175
  - spec/commenter/cli_spec.rb
154
176
  - spec/commenter/comment_sheet_spec.rb
155
177
  - spec/commenter/comment_spec.rb
156
178
  - spec/commenter/comment_type_spec.rb
179
+ - spec/commenter/disposition_status_spec.rb
180
+ - spec/commenter/filler_spec.rb
157
181
  - spec/commenter/github_integration_spec.rb
158
182
  - spec/commenter/osd_xlsx_parser_spec.rb
159
183
  - spec/commenter/track_change_docx_parser_spec.rb