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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/CLAUDE.md +17 -7
  4. data/CONTEXT.md +27 -0
  5. data/README.adoc +92 -17
  6. data/commenter.gemspec +1 -0
  7. data/lib/commenter/ballot.rb +67 -0
  8. data/lib/commenter/ballot_report.rb +60 -0
  9. data/lib/commenter/cli.rb +62 -43
  10. data/lib/commenter/comment.rb +103 -126
  11. data/lib/commenter/comment_locality.rb +18 -0
  12. data/lib/commenter/comment_sheet.rb +31 -63
  13. data/lib/commenter/comment_type.rb +39 -0
  14. data/lib/commenter/disposition_status.rb +37 -0
  15. data/lib/commenter/filler.rb +91 -59
  16. data/lib/commenter/github_info.rb +23 -0
  17. data/lib/commenter/github_integration.rb +17 -71
  18. data/lib/commenter/github_session.rb +35 -0
  19. data/lib/commenter/parser/osd_xlsx_parser.rb +1 -12
  20. data/lib/commenter/parser/track_change_docx_parser.rb +330 -0
  21. data/lib/commenter/parser.rb +10 -13
  22. data/lib/commenter/version.rb +1 -1
  23. data/lib/commenter.rb +16 -5
  24. data/spec/commenter/ballot_report_spec.rb +62 -0
  25. data/spec/commenter/ballot_spec.rb +68 -0
  26. data/spec/commenter/cli_spec.rb +41 -0
  27. data/spec/commenter/comment_sheet_spec.rb +35 -10
  28. data/spec/commenter/comment_spec.rb +22 -9
  29. data/spec/commenter/comment_type_spec.rb +45 -0
  30. data/spec/commenter/disposition_status_spec.rb +40 -0
  31. data/spec/commenter/filler_spec.rb +119 -0
  32. data/spec/commenter/github_integration_spec.rb +65 -32
  33. data/spec/commenter/osd_xlsx_parser_spec.rb +3 -2
  34. data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
  35. data/spec/support/redline_docx_builder.rb +95 -0
  36. data/spec/support/xlsx_builder.rb +17 -12
  37. metadata +32 -2
@@ -1,9 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "docx"
4
+ require "zip"
4
5
 
5
6
  module Commenter
6
7
  class Filler
8
+ # Labels of the sheet metadata fields in the template's page header
9
+ # (word/header*.xml). Values supplied to #fill are appended after each
10
+ # label; the docx gem cannot write header parts (see docx PR #73), so
11
+ # this is done by rewriting the entry directly.
12
+ HEADER_METADATA = { date: "Date", document: "Document", project: "Project" }.freeze
13
+
7
14
  def fill(template_path, output_path, comments, options = {})
8
15
  doc = Docx::Document.open(template_path)
9
16
  table = doc.tables.first
@@ -11,48 +18,88 @@ module Commenter
11
18
  raise "No table found in template" unless table
12
19
  raise "Template table must have at least one row" if table.row_count < 1
13
20
 
14
- # Get the template row (first row in the table)
15
- template_row = table.rows.first
16
-
17
- # Add new rows for each comment by copying the template row
18
- comments.each_with_index do |comment, _index|
19
- # Convert comment to symbol keys for consistent access
20
- comment_data = symbolize_keys(comment)
21
-
22
- # Copy the template row and insert it
23
- begin
24
- new_row = template_row.copy
25
- new_row.insert_before(template_row)
26
- row = new_row
27
- rescue StandardError => e
28
- puts "Warning: Could not add row for comment #{comment_data[:id]}: #{e.message}"
29
- next
30
- end
21
+ # The template row (first row in the table) is copied for each comment;
22
+ # all original template rows are removed afterwards via their XML
23
+ # nodes (the shipped 2012-03 template carries extra blank rows beyond
24
+ # the first, and the docx gem's Row exposes no #remove).
25
+ template_rows = table.rows.to_a
26
+ comments.each { |comment| fill_row(template_rows.first, comment, options) }
27
+ template_rows.each { |row| row.node.remove }
28
+
29
+ doc.save(output_path)
30
+ write_header_metadata(output_path, options)
31
+ output_path
32
+ end
33
+
34
+ private
35
+
36
+ def fill_row(template_row, comment, options)
37
+ comment = Comment.from_hash(comment) unless comment.is_a?(Comment)
38
+
39
+ new_row = template_row.copy
40
+ new_row.insert_before(template_row)
41
+
42
+ set_cell_text(new_row.cells[0], comment.id.to_s)
43
+ set_cell_text(new_row.cells[1], comment.line_number.to_s)
44
+ set_cell_text(new_row.cells[2], comment.clause.to_s)
45
+ set_cell_text(new_row.cells[3], comment.element.to_s)
46
+ set_cell_text(new_row.cells[4], comment.type.to_s)
47
+ set_cell_text(new_row.cells[5], comment.comments.to_s)
48
+ set_cell_text(new_row.cells[6], comment.proposed_change.to_s)
49
+
50
+ observations = comment.observations.to_s
51
+ return if observations.empty?
52
+
53
+ set_cell_text(new_row.cells[7], observations)
54
+ apply_shading(new_row.cells[7], observations) if options[:shading]
55
+ rescue StandardError => e
56
+ puts "Warning: Could not add row for comment #{comment.id}: #{e.message}"
57
+ end
58
+
59
+ def write_header_metadata(output_path, options)
60
+ metadata = HEADER_METADATA.map do |key, label|
61
+ value = options[key].to_s.strip
62
+ value.empty? ? nil : [label, value]
63
+ end.compact
64
+ return if metadata.empty?
31
65
 
32
- # Map comment to table cells using text substitution
33
- set_cell_text(row.cells[0], comment_data[:id] || "")
34
- set_cell_text(row.cells[1], comment_data.dig(:locality, :line_number) || "")
35
- set_cell_text(row.cells[2], comment_data.dig(:locality, :clause) || "")
36
- set_cell_text(row.cells[3], comment_data.dig(:locality, :element) || "")
37
- set_cell_text(row.cells[4], comment_data[:type] || "")
38
- set_cell_text(row.cells[5], comment_data[:comments] || "")
39
- set_cell_text(row.cells[6], comment_data[:proposed_change] || "")
40
-
41
- # Handle observations with optional shading
42
- observations = comment_data[:observations]
43
- if observations && !observations.empty?
44
- set_cell_text(row.cells[7], observations)
45
- apply_shading(row.cells[7], observations) if options[:shading]
66
+ rewrite_headers(output_path, metadata)
67
+ end
68
+
69
+ def rewrite_headers(output_path, metadata)
70
+ # In-memory buffer + File.binwrite rather than rewriting the archive
71
+ # in place: rubyzip commits in-place edits with File.rename, which
72
+ # fails with EACCES on Windows.
73
+ buffer = Zip::OutputStream.write_buffer do |out|
74
+ Zip::File.open(output_path) do |zip|
75
+ zip.entries.each do |entry|
76
+ # Block form: the entry stream is closed eagerly, otherwise its
77
+ # handle keeps the file locked on Windows and the binwrite below
78
+ # fails with EACCES.
79
+ content = entry.get_input_stream(&:read)
80
+ out.put_next_entry(entry.name)
81
+ out.write(patch_header(entry.name, content, metadata))
82
+ end
46
83
  end
47
84
  end
85
+ File.binwrite(output_path, buffer.string)
86
+ end
48
87
 
49
- # Remove the original template row after all comments are added
50
- template_row.remove if template_row.respond_to?(:remove)
88
+ def patch_header(entry_name, content, metadata)
89
+ return content unless entry_name.match?(%r{\Aword/header\d*\.xml\z})
51
90
 
52
- doc.save(output_path)
91
+ xml = content.dup.force_encoding(Encoding::UTF_8)
92
+ metadata.each do |label, value|
93
+ xml.sub!(%r{(<w:t[^>]*>)\s*#{label}:\s*(</w:t>)}) do
94
+ "#{Regexp.last_match(1)}#{label}: #{escape(value)}#{Regexp.last_match(2)}"
95
+ end
96
+ end
97
+ xml
53
98
  end
54
99
 
55
- private
100
+ def escape(text)
101
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
102
+ end
56
103
 
57
104
  def set_cell_text(cell, text)
58
105
  return if text.nil? || text.empty?
@@ -86,14 +133,16 @@ module Commenter
86
133
  puts "Warning: Could not set text '#{text}' in cell: #{e.message}"
87
134
  end
88
135
 
89
- def symbolize_keys(hash)
90
- return hash unless hash.is_a?(Hash)
136
+ SHADING_COLORS = {
137
+ accept_with_modifications: "C4D79B", # Olive Green
138
+ accepted: "92D050", # Green
139
+ noted: "8DB4E2", # Blue
140
+ rejected: "FF99CC", # Pink
141
+ todo: "D9D9D9" # Light Gray (for diagonal stripes, we use solid for now)
142
+ }.freeze
91
143
 
92
- hash.each_with_object({}) do |(key, value), result|
93
- new_key = key.to_sym
94
- new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
95
- result[new_key] = new_value
96
- end
144
+ def determine_shading_color(observation)
145
+ SHADING_COLORS[DispositionStatus.match(observation)]
97
146
  end
98
147
 
99
148
  def apply_shading(cell, observation)
@@ -111,23 +160,6 @@ module Commenter
111
160
  puts "Warning: Could not apply shading to cell: #{e.message}"
112
161
  end
113
162
 
114
- def determine_shading_color(observation)
115
- text = observation.downcase.strip
116
-
117
- case text
118
- when /awm|accept with modifications/
119
- "C4D79B" # Olive Green
120
- when /accept(ed)?/
121
- "92D050" # Green
122
- when /noted/
123
- "8DB4E2" # Blue
124
- when /reject(ed)?/
125
- "FF99CC" # Pink
126
- when /todo/
127
- "D9D9D9" # Light Gray (for diagonal stripes, we'll use solid for now)
128
- end
129
- end
130
-
131
163
  def apply_cell_shading(cell, color)
132
164
  # Access the cell's XML node
133
165
  cell_node = cell.node
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Commenter
6
+ # GitHub issue tracking state attached to a comment by github-create /
7
+ # github-retrieve.
8
+ class GithubInfo < Lutaml::Model::Serializable
9
+ attribute :issue_number, :integer
10
+ attribute :issue_url, :string
11
+ attribute :status, :string
12
+ attribute :created_at, :string
13
+ attribute :updated_at, :string
14
+
15
+ yaml do
16
+ map "issue_number", to: :issue_number
17
+ map "issue_url", to: :issue_url
18
+ map "status", to: :status
19
+ map "created_at", to: :created_at
20
+ map "updated_at", to: :updated_at
21
+ end
22
+ end
23
+ end
@@ -8,11 +8,10 @@ require "dotenv/load"
8
8
  module Commenter
9
9
  class GitHubIssueCreator
10
10
  def initialize(config_path, title_template_path = nil, body_template_path = nil)
11
- @config = load_config(config_path)
12
- @github_client = create_github_client
13
- @repo = @config.dig("github", "repository")
14
-
15
- raise "GitHub repository not specified in config" unless @repo
11
+ @session = GitHubSession.new(config_path)
12
+ @config = @session.config
13
+ @github_client = @session.client
14
+ @repo = @session.repo
16
15
 
17
16
  @title_template = load_liquid_template(title_template_path || default_title_template_path)
18
17
  @body_template = load_liquid_template(body_template_path || default_body_template_path)
@@ -20,8 +19,7 @@ module Commenter
20
19
  end
21
20
 
22
21
  def create_issues_from_yaml(yaml_file, options = {})
23
- data = YAML.load_file(yaml_file)
24
- comment_sheet = CommentSheet.from_hash(data)
22
+ comment_sheet = CommentSheet.from_yaml(File.read(yaml_file))
25
23
 
26
24
  # Override stage if provided
27
25
  comment_sheet.stage = options[:stage] if options[:stage]
@@ -43,19 +41,6 @@ module Commenter
43
41
 
44
42
  private
45
43
 
46
- def load_config(config_path)
47
- YAML.load_file(config_path)
48
- rescue Errno::ENOENT
49
- raise "Configuration file not found: #{config_path}"
50
- end
51
-
52
- def create_github_client
53
- token = @config.dig("github", "token") || ENV["GITHUB_TOKEN"]
54
- raise "GitHub token not found. Set GITHUB_TOKEN environment variable or specify in config file." unless token
55
-
56
- Octokit::Client.new(access_token: token)
57
- end
58
-
59
44
  def default_title_template_path
60
45
  File.join(__dir__, "../../data/github_issue_title_template.liquid")
61
46
  end
@@ -103,7 +88,7 @@ module Commenter
103
88
  "comment_id" => comment.id || "",
104
89
  "body" => comment.body || "",
105
90
  "type" => comment.type || "",
106
- "type_full_name" => expand_comment_type(comment.type),
91
+ "type_full_name" => CommentType.display_name(comment.type),
107
92
  "comments" => comment.comments || "",
108
93
  "proposed_change" => comment.proposed_change || "",
109
94
  "observations" => comment.observations || "",
@@ -134,15 +119,6 @@ module Commenter
134
119
  @unique_id_template.render(base_variables).strip
135
120
  end
136
121
 
137
- def expand_comment_type(type)
138
- case type&.downcase
139
- when "ge", "general" then "General"
140
- when "te", "technical" then "Technical"
141
- when "ed", "editorial" then "Editorial"
142
- else type || "Unknown"
143
- end
144
- end
145
-
146
122
  def format_locality(comment)
147
123
  parts = []
148
124
  parts << "Clause #{comment.clause}" if comment.clause && !comment.clause.strip.empty?
@@ -305,37 +281,26 @@ module Commenter
305
281
  comment = comment_sheet.comments.find { |c| c.id == result[:comment_id] }
306
282
  next unless comment
307
283
 
308
- # Add GitHub information to the comment
309
- comment.github[:issue_number] = result[:issue_number]
310
- comment.github[:issue_url] = result[:issue_url]
311
- comment.github[:status] = "open"
312
- comment.github[:created_at] = Time.now.utc.iso8601
284
+ comment.record_github_issue(issue_number: result[:issue_number], issue_url: result[:issue_url],
285
+ status: "open", created_at: Time.now.utc.iso8601)
313
286
  end
314
287
 
315
288
  # Write updated YAML
316
289
  output_file = options[:output] || yaml_file
317
- yaml_content = generate_yaml_with_header(comment_sheet.to_yaml_h)
318
- File.write(output_file, yaml_content)
319
- end
320
-
321
- def generate_yaml_with_header(data)
322
- header = "# yaml-language-server: $schema=schema/iso_comment_2012-03.yaml\n\n"
323
- header + data.to_yaml
290
+ File.write(output_file, comment_sheet.to_yaml_document)
324
291
  end
325
292
  end
326
293
 
327
294
  class GitHubIssueRetriever
328
295
  def initialize(config_path)
329
- @config = load_config(config_path)
330
- @github_client = create_github_client
331
- @repo = @config.dig("github", "repository")
332
-
333
- raise "GitHub repository not specified in config" unless @repo
296
+ @session = GitHubSession.new(config_path)
297
+ @config = @session.config
298
+ @github_client = @session.client
299
+ @repo = @session.repo
334
300
  end
335
301
 
336
302
  def retrieve_observations_from_yaml(yaml_file, options = {})
337
- data = YAML.load_file(yaml_file)
338
- comment_sheet = CommentSheet.from_hash(data)
303
+ comment_sheet = CommentSheet.from_yaml(File.read(yaml_file))
339
304
 
340
305
  results = []
341
306
  comment_sheet.comments.each do |comment|
@@ -357,19 +322,6 @@ module Commenter
357
322
 
358
323
  private
359
324
 
360
- def load_config(config_path)
361
- YAML.load_file(config_path)
362
- rescue Errno::ENOENT
363
- raise "Configuration file not found: #{config_path}"
364
- end
365
-
366
- def create_github_client
367
- token = @config.dig("github", "token") || ENV["GITHUB_TOKEN"]
368
- raise "GitHub token not found. Set GITHUB_TOKEN environment variable or specify in config file." unless token
369
-
370
- Octokit::Client.new(access_token: token)
371
- end
372
-
373
325
  def retrieve_observation(comment, options)
374
326
  issue_number = comment.github_issue_number
375
327
 
@@ -392,8 +344,8 @@ module Commenter
392
344
  if observation
393
345
  # Update comment with observation and current status
394
346
  comment.observations = observation
395
- comment.github[:status] = issue.state
396
- comment.github[:updated_at] = Time.now.utc.iso8601
347
+ comment.github.status = issue.state
348
+ comment.github.updated_at = Time.now.utc.iso8601
397
349
 
398
350
  {
399
351
  comment_id: comment.id,
@@ -486,13 +438,7 @@ module Commenter
486
438
 
487
439
  def update_yaml_with_observations(yaml_file, comment_sheet, options)
488
440
  output_file = options[:output] || yaml_file
489
- yaml_content = generate_yaml_with_header(comment_sheet.to_yaml_h)
490
- File.write(output_file, yaml_content)
491
- end
492
-
493
- def generate_yaml_with_header(data)
494
- header = "# yaml-language-server: $schema=schema/iso_comment_2012-03.yaml\n\n"
495
- header + data.to_yaml
441
+ File.write(output_file, comment_sheet.to_yaml_document)
496
442
  end
497
443
  end
498
444
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "octokit"
4
+ require "yaml"
5
+
6
+ module Commenter
7
+ # Shared connection setup for the GitHub integration commands: loads the
8
+ # configuration file, builds the Octokit client, and resolves the target
9
+ # repository. One constructor, one test surface.
10
+ class GitHubSession
11
+ attr_reader :config, :client, :repo
12
+
13
+ def initialize(config_path)
14
+ @config = load_config(config_path)
15
+ @client = Octokit::Client.new(access_token: required_token)
16
+ @repo = @config.dig("github", "repository")
17
+ raise "GitHub repository not specified in config" unless @repo
18
+ end
19
+
20
+ private
21
+
22
+ def load_config(config_path)
23
+ YAML.load_file(config_path)
24
+ rescue Errno::ENOENT
25
+ raise "Configuration file not found: #{config_path}"
26
+ end
27
+
28
+ def required_token
29
+ token = @config.dig("github", "token") || ENV["GITHUB_TOKEN"]
30
+ raise "GitHub token not found. Set GITHUB_TOKEN environment variable or specify in config file." unless token
31
+
32
+ token
33
+ end
34
+ end
35
+ end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "roo"
4
- require_relative "../comment_sheet"
5
- require_relative "../comment"
6
4
 
7
5
  module Commenter
8
6
  class Parser
@@ -201,7 +199,7 @@ module Commenter
201
199
  clause: clause.empty? ? nil : clause,
202
200
  element: clause_title.empty? ? nil : clause_title
203
201
  }.compact,
204
- type: normalize_comment_type(cell_value_str(row, col_map[:comment_type]).to_s),
202
+ type: CommentType.full_name(cell_value_str(row, col_map[:comment_type])),
205
203
  comments: comment_text.to_s.strip,
206
204
  proposed_change: cell_value_str(row, col_map[:proposed_change]) ||
207
205
  cell_value_str(row, col_map[:proposal_on_text])
@@ -227,15 +225,6 @@ module Commenter
227
225
  parts.empty? ? nil : parts.join(". ")
228
226
  end
229
227
 
230
- def normalize_comment_type(type_str)
231
- case type_str.strip.downcase
232
- when "editorial", "ed" then "ed"
233
- when "technical", "te" then "te"
234
- when "general", "ge" then "ge"
235
- else type_str.strip
236
- end
237
- end
238
-
239
228
  def cell_value(row, index)
240
229
  return nil if index.nil? || row.length <= index
241
230