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.
@@ -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)
@@ -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" => expand_comment_type(comment.type),
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
- 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
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
- @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
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
- 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
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