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
data/lib/commenter/filler.rb
CHANGED
|
@@ -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
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
88
|
+
def patch_header(entry_name, content, metadata)
|
|
89
|
+
return content unless entry_name.match?(%r{\Aword/header\d*\.xml\z})
|
|
51
90
|
|
|
52
|
-
|
|
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
|
-
|
|
100
|
+
def escape(text)
|
|
101
|
+
text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
@
|
|
12
|
-
@
|
|
13
|
-
@
|
|
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
|
-
|
|
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" =>
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
330
|
-
@
|
|
331
|
-
@
|
|
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
|
-
|
|
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
|
|
396
|
-
comment.github
|
|
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
|
-
|
|
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:
|
|
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
|
|