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.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -0
- data/CLAUDE.md +68 -0
- data/README.adoc +115 -14
- data/commenter.gemspec +3 -2
- data/lib/commenter/cli.rb +23 -25
- data/lib/commenter/comment.rb +23 -8
- data/lib/commenter/comment_sheet.rb +18 -2
- 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 +243 -0
- data/lib/commenter/parser/track_change_docx_parser.rb +332 -0
- data/lib/commenter/parser.rb +93 -37
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +5 -0
- data/schema/iso_comment_2012-03.yaml +2 -2
- data/schema/iso_comment_osd.yaml +112 -0
- data/spec/commenter/cli_spec.rb +35 -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/osd_xlsx_parser_spec.rb +180 -0
- data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
- data/spec/support/osd_fixtures.rb +94 -0
- data/spec/support/redline_docx_builder.rb +88 -0
- data/spec/support/xlsx_builder.rb +147 -0
- metadata +32 -5
|
@@ -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)
|
|
@@ -43,19 +42,6 @@ module Commenter
|
|
|
43
42
|
|
|
44
43
|
private
|
|
45
44
|
|
|
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
45
|
def default_title_template_path
|
|
60
46
|
File.join(__dir__, "../../data/github_issue_title_template.liquid")
|
|
61
47
|
end
|
|
@@ -103,7 +89,7 @@ module Commenter
|
|
|
103
89
|
"comment_id" => comment.id || "",
|
|
104
90
|
"body" => comment.body || "",
|
|
105
91
|
"type" => comment.type || "",
|
|
106
|
-
"type_full_name" =>
|
|
92
|
+
"type_full_name" => CommentType.display_name(comment.type),
|
|
107
93
|
"comments" => comment.comments || "",
|
|
108
94
|
"proposed_change" => comment.proposed_change || "",
|
|
109
95
|
"observations" => comment.observations || "",
|
|
@@ -134,15 +120,6 @@ module Commenter
|
|
|
134
120
|
@unique_id_template.render(base_variables).strip
|
|
135
121
|
end
|
|
136
122
|
|
|
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
123
|
def format_locality(comment)
|
|
147
124
|
parts = []
|
|
148
125
|
parts << "Clause #{comment.clause}" if comment.clause && !comment.clause.strip.empty?
|
|
@@ -314,23 +291,16 @@ module Commenter
|
|
|
314
291
|
|
|
315
292
|
# Write updated YAML
|
|
316
293
|
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
|
|
294
|
+
File.write(output_file, comment_sheet.to_yaml_document)
|
|
324
295
|
end
|
|
325
296
|
end
|
|
326
297
|
|
|
327
298
|
class GitHubIssueRetriever
|
|
328
299
|
def initialize(config_path)
|
|
329
|
-
@
|
|
330
|
-
@
|
|
331
|
-
@
|
|
332
|
-
|
|
333
|
-
raise "GitHub repository not specified in config" unless @repo
|
|
300
|
+
@session = GitHubSession.new(config_path)
|
|
301
|
+
@config = @session.config
|
|
302
|
+
@github_client = @session.client
|
|
303
|
+
@repo = @session.repo
|
|
334
304
|
end
|
|
335
305
|
|
|
336
306
|
def retrieve_observations_from_yaml(yaml_file, options = {})
|
|
@@ -357,19 +327,6 @@ module Commenter
|
|
|
357
327
|
|
|
358
328
|
private
|
|
359
329
|
|
|
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
330
|
def retrieve_observation(comment, options)
|
|
374
331
|
issue_number = comment.github_issue_number
|
|
375
332
|
|
|
@@ -486,13 +443,7 @@ module Commenter
|
|
|
486
443
|
|
|
487
444
|
def update_yaml_with_observations(yaml_file, comment_sheet, options)
|
|
488
445
|
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
|
|
446
|
+
File.write(output_file, comment_sheet.to_yaml_document)
|
|
496
447
|
end
|
|
497
448
|
end
|
|
498
449
|
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
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "roo"
|
|
4
|
+
require_relative "../comment_sheet"
|
|
5
|
+
require_relative "../comment"
|
|
6
|
+
|
|
7
|
+
module Commenter
|
|
8
|
+
class Parser
|
|
9
|
+
# Parser for ISO Online Standards Development (OSD) XLSX exports.
|
|
10
|
+
#
|
|
11
|
+
# Supports two variants, detected from the header row:
|
|
12
|
+
# - "resolved": single "Comments (N)" sheet with resolution data
|
|
13
|
+
# (columns: Comment ID, User name, Clause nb, ..., Resolution status,
|
|
14
|
+
# Motivation, Resolution Date, Stage code)
|
|
15
|
+
# - "unresolved": multi-sheet format with "Comments (N)",
|
|
16
|
+
# "Unresolved comments (N)", "Resolved comments (N)" sheets
|
|
17
|
+
# (columns: User name, Clause nb, ..., Comment type, Comment/Motivation,
|
|
18
|
+
# Comment on text, Proposal on Text, Proposed change, Replies,
|
|
19
|
+
# Resolution status, Justification, Resolution Date, Date, Comment number)
|
|
20
|
+
#
|
|
21
|
+
# Both variants share the same header structure in the first rows:
|
|
22
|
+
# Row 0: [Date, Reference, nil, "", Title EN, nil, nil, Title FR, ...]
|
|
23
|
+
# Row 1: ["2026-04-21", "ISO/DIS 5843-6(en)", ...]
|
|
24
|
+
# Row 2: empty
|
|
25
|
+
# Row 3: column headers
|
|
26
|
+
# Row 4+: data
|
|
27
|
+
class OsdXlsxParser
|
|
28
|
+
# Header names are mapped per variant onto common attribute keys so both
|
|
29
|
+
# variants share a single comment builder. Headers are mapped by real
|
|
30
|
+
# column position, tolerating gaps in the header row.
|
|
31
|
+
RESOLVED_COLUMN_KEYS = {
|
|
32
|
+
"Comment ID" => :id,
|
|
33
|
+
"User name" => :user_name,
|
|
34
|
+
"Clause nb" => :clause,
|
|
35
|
+
"Clause Title" => :clause_title,
|
|
36
|
+
"Subtype" => :comment_type,
|
|
37
|
+
"Comment" => :comment_text,
|
|
38
|
+
"Proposal on Text" => :proposal_on_text,
|
|
39
|
+
"Proposed change" => :proposed_change,
|
|
40
|
+
"Feedbacks" => :feedbacks,
|
|
41
|
+
"Created Date" => :created_date,
|
|
42
|
+
"Resolution status" => :resolution_status,
|
|
43
|
+
"Motivation" => :motivation,
|
|
44
|
+
"Resolution Date" => :resolution_date,
|
|
45
|
+
"Stage code" => :stage_code
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
UNRESOLVED_COLUMN_KEYS = {
|
|
49
|
+
"User name" => :user_name,
|
|
50
|
+
"Clause nb" => :clause,
|
|
51
|
+
"Clause Title" => :clause_title,
|
|
52
|
+
"Comment type" => :comment_type,
|
|
53
|
+
"Comment/Motivation" => :comment_text,
|
|
54
|
+
"Proposal on Text" => :proposal_on_text,
|
|
55
|
+
"Proposed change" => :proposed_change,
|
|
56
|
+
"Replies" => :feedbacks,
|
|
57
|
+
"Resolution status" => :resolution_status,
|
|
58
|
+
"Justification" => :motivation,
|
|
59
|
+
"Resolution Date" => :resolution_date,
|
|
60
|
+
"Date" => :created_date,
|
|
61
|
+
"Comment number" => :id
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
def parse(xlsx_path, options = {})
|
|
65
|
+
xlsx = Roo::Spreadsheet.open(xlsx_path)
|
|
66
|
+
|
|
67
|
+
sheet_name = select_sheet(xlsx, options)
|
|
68
|
+
sheet = xlsx.sheet(sheet_name)
|
|
69
|
+
header_row_num = find_header_row(sheet)
|
|
70
|
+
variant = detect_variant(sheet.row(header_row_num))
|
|
71
|
+
|
|
72
|
+
comments = parse_comments(sheet, header_row_num, variant, options)
|
|
73
|
+
CommentSheet.new(version: "osd", **extract_metadata(xlsx, sheet_name), comments: comments)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def select_sheet(xlsx, options)
|
|
79
|
+
sheets = xlsx.sheets
|
|
80
|
+
|
|
81
|
+
if options[:sheet]
|
|
82
|
+
raise "Sheet '#{options[:sheet]}' not found. Available: #{sheets.join(", ")}" unless sheets.include?(options[:sheet])
|
|
83
|
+
|
|
84
|
+
return options[:sheet]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if options[:resolved_only]
|
|
88
|
+
sheets.find { |s| s.start_with?("Resolved") } || sheets.find { |s| s.start_with?("Comments") }
|
|
89
|
+
elsif options[:unresolved_only]
|
|
90
|
+
sheets.find { |s| s.start_with?("Unresolved") } || sheets.find { |s| s.start_with?("Comments") }
|
|
91
|
+
else
|
|
92
|
+
sheets.first # Default: first sheet (usually "Comments (N)" with all comments)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def find_header_row(sheet)
|
|
97
|
+
(1..10).each do |row_num|
|
|
98
|
+
headers = sheet.row(row_num).to_a.compact.map(&:to_s)
|
|
99
|
+
return row_num if headers.include?("User name") || headers.include?("Comment ID")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raise "Could not find header row in XLSX sheet"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def detect_variant(header_row)
|
|
106
|
+
headers = header_row.to_a.compact.map(&:to_s)
|
|
107
|
+
if headers.include?("Comment ID") || headers.include?("Subtype")
|
|
108
|
+
:resolved
|
|
109
|
+
elsif headers.include?("Comment/Motivation") || headers.include?("Comment type")
|
|
110
|
+
:unresolved
|
|
111
|
+
else
|
|
112
|
+
headers.length >= 15 ? :resolved : :unresolved
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def extract_metadata(xlsx, sheet_name)
|
|
117
|
+
values = xlsx.sheet(sheet_name).row(2)
|
|
118
|
+
metadata = { date: nil, document: nil, project: nil, stage: nil, title_en: nil, title_fr: nil }
|
|
119
|
+
return metadata if values.nil?
|
|
120
|
+
|
|
121
|
+
metadata[:date] = header_value(values[0], "Date")
|
|
122
|
+
|
|
123
|
+
reference = header_value(values[1], "Reference")
|
|
124
|
+
if reference
|
|
125
|
+
metadata[:document] = reference
|
|
126
|
+
metadata[:stage] = extract_stage(reference)
|
|
127
|
+
metadata[:project] = extract_project(reference)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
metadata[:title_en] = header_value(values[4], "Title EN")
|
|
131
|
+
metadata[:title_fr] = header_value(values[7], "Title FR")
|
|
132
|
+
metadata
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def header_value(raw, label)
|
|
136
|
+
return nil if raw.nil?
|
|
137
|
+
|
|
138
|
+
value = raw.is_a?(Date) ? raw.strftime("%Y-%m-%d") : raw.to_s.strip
|
|
139
|
+
value.empty? || value == label ? nil : value
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def extract_stage(reference)
|
|
143
|
+
case reference
|
|
144
|
+
when %r{/WD\b}i then "WD"
|
|
145
|
+
when %r{/CD\b}i then "CD"
|
|
146
|
+
when %r{/DIS\b}i then "DIS"
|
|
147
|
+
when %r{/FDIS\b}i then "FDIS"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def extract_project(reference)
|
|
152
|
+
# Extract ISO number from reference like "ISO/DIS 5843-6(en)"
|
|
153
|
+
match = reference.match(%r{(ISO[\s/]*\w*\s*\d+[\d-]*)})
|
|
154
|
+
match ? match[1].strip.gsub(/\s+/, " ") : nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def parse_comments(sheet, header_row_num, variant, options)
|
|
158
|
+
column_keys = variant == :resolved ? RESOLVED_COLUMN_KEYS : UNRESOLVED_COLUMN_KEYS
|
|
159
|
+
col_map = build_column_map(sheet.row(header_row_num), column_keys)
|
|
160
|
+
|
|
161
|
+
comments = []
|
|
162
|
+
(header_row_num + 1..sheet.last_row).each do |row_num|
|
|
163
|
+
row = sheet.row(row_num)
|
|
164
|
+
next if row.nil? || row.to_a.compact.empty?
|
|
165
|
+
|
|
166
|
+
comment = build_comment(row, col_map, options)
|
|
167
|
+
comments << comment if comment
|
|
168
|
+
end
|
|
169
|
+
comments
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def build_column_map(header_row, column_keys)
|
|
173
|
+
header_row.to_a.each_with_index.with_object({}) do |(header, index), map|
|
|
174
|
+
key = column_keys[header.to_s.strip]
|
|
175
|
+
map[key] = index if key
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_comment(row, col_map, options)
|
|
180
|
+
id = cell_value(row, col_map[:id])
|
|
181
|
+
return nil if id.nil?
|
|
182
|
+
|
|
183
|
+
id_str = id.is_a?(Float) ? id.to_i.to_s : id.to_s
|
|
184
|
+
comment_text = cell_value(row, col_map[:comment_text]) || ""
|
|
185
|
+
return nil if comment_text.strip.empty? && id_str.empty?
|
|
186
|
+
|
|
187
|
+
attrs = base_attributes(row, col_map, id_str, comment_text)
|
|
188
|
+
.merge(resolution_attributes(row, col_map))
|
|
189
|
+
attrs[:observations] = resolution_observations(row, col_map) unless options[:exclude_observations]
|
|
190
|
+
Comment.new(attrs)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def base_attributes(row, col_map, id_str, comment_text)
|
|
194
|
+
user_name = cell_value_str(row, col_map[:user_name]).to_s
|
|
195
|
+
clause = cell_value_str(row, col_map[:clause]).to_s
|
|
196
|
+
clause_title = cell_value_str(row, col_map[:clause_title]).to_s
|
|
197
|
+
{
|
|
198
|
+
id: id_str,
|
|
199
|
+
body: user_name,
|
|
200
|
+
locality: {
|
|
201
|
+
clause: clause.empty? ? nil : clause,
|
|
202
|
+
element: clause_title.empty? ? nil : clause_title
|
|
203
|
+
}.compact,
|
|
204
|
+
type: CommentType.code(cell_value_str(row, col_map[:comment_type]).to_s),
|
|
205
|
+
comments: comment_text.to_s.strip,
|
|
206
|
+
proposed_change: cell_value_str(row, col_map[:proposed_change]) ||
|
|
207
|
+
cell_value_str(row, col_map[:proposal_on_text])
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def resolution_attributes(row, col_map)
|
|
212
|
+
{
|
|
213
|
+
user_name: cell_value_str(row, col_map[:user_name]).to_s,
|
|
214
|
+
comment_type: cell_value_str(row, col_map[:comment_type]).to_s,
|
|
215
|
+
resolution_status: cell_value_str(row, col_map[:resolution_status]),
|
|
216
|
+
resolution_date: cell_value_str(row, col_map[:resolution_date]),
|
|
217
|
+
feedbacks: cell_value_str(row, col_map[:feedbacks]),
|
|
218
|
+
motivation: cell_value_str(row, col_map[:motivation]),
|
|
219
|
+
created_date: cell_value_str(row, col_map[:created_date]),
|
|
220
|
+
stage_code: cell_value_str(row, col_map[:stage_code])
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def resolution_observations(row, col_map)
|
|
225
|
+
parts = [cell_value_str(row, col_map[:resolution_status]),
|
|
226
|
+
cell_value_str(row, col_map[:motivation])].compact
|
|
227
|
+
parts.empty? ? nil : parts.join(". ")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def cell_value(row, index)
|
|
231
|
+
return nil if index.nil? || row.length <= index
|
|
232
|
+
|
|
233
|
+
value = row[index]
|
|
234
|
+
value.is_a?(String) && value.strip.empty? ? nil : value
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def cell_value_str(row, index)
|
|
238
|
+
value = cell_value(row, index)
|
|
239
|
+
value.nil? || value.to_s.strip.empty? ? nil : value.to_s.strip
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|