commenter 0.2.2 → 0.3.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.
@@ -2,7 +2,9 @@
2
2
 
3
3
  module Commenter
4
4
  class Comment
5
- attr_accessor :id, :body, :locality, :type, :comments, :proposed_change, :observations, :github
5
+ attr_accessor :id, :body, :locality, :type, :comments, :proposed_change, :observations, :github,
6
+ :user_name, :comment_type, :resolution_status, :resolution_date, :feedbacks,
7
+ :motivation, :created_date, :stage_code
6
8
 
7
9
  def initialize(attributes = {})
8
10
  # Normalize input to symbols
@@ -11,11 +13,33 @@ module Commenter
11
13
  @id = attrs[:id]
12
14
  @body = attrs[:body]
13
15
  @locality = symbolize_keys(attrs[:locality] || {})
14
- @type = attrs[:type]
16
+ @type = expand_comment_type(attrs[:type])
15
17
  @comments = attrs[:comments]
16
18
  @proposed_change = attrs[:proposed_change]
17
19
  @observations = attrs[:observations]
18
20
  @github = symbolize_keys(attrs[:github] || {})
21
+
22
+ # OSD-specific fields
23
+ @user_name = attrs[:user_name]
24
+ @comment_type = attrs[:comment_type]
25
+ @resolution_status = attrs[:resolution_status]
26
+ @resolution_date = attrs[:resolution_date]
27
+ @feedbacks = attrs[:feedbacks]
28
+ @motivation = attrs[:motivation]
29
+ @created_date = attrs[:created_date]
30
+ @stage_code = attrs[:stage_code]
31
+ end
32
+
33
+ # Expands short type codes to full names. One-way by design: already
34
+ # expanded values pass through unchanged so that reloading YAML output
35
+ # stays stable.
36
+ def expand_comment_type(type)
37
+ case type&.downcase
38
+ when "ge" then "general"
39
+ when "te" then "technical"
40
+ when "ed" then "editorial"
41
+ else type
42
+ end
19
43
  end
20
44
 
21
45
  def line_number
@@ -108,6 +132,14 @@ module Commenter
108
132
  comments: @comments,
109
133
  proposed_change: @proposed_change,
110
134
  observations: @observations,
135
+ user_name: @user_name,
136
+ comment_type: @comment_type,
137
+ resolution_status: @resolution_status,
138
+ resolution_date: @resolution_date,
139
+ feedbacks: @feedbacks,
140
+ motivation: @motivation,
141
+ created_date: @created_date,
142
+ stage_code: @stage_code,
111
143
  github: @github.empty? ? nil : @github
112
144
  }.compact
113
145
  end
@@ -4,7 +4,7 @@ require_relative "comment"
4
4
 
5
5
  module Commenter
6
6
  class CommentSheet
7
- attr_accessor :version, :date, :document, :project, :stage, :comments
7
+ attr_accessor :version, :date, :document, :project, :stage, :comments, :title_en, :title_fr
8
8
 
9
9
  def initialize(attributes = {})
10
10
  # Normalize input to symbols
@@ -15,6 +15,8 @@ module Commenter
15
15
  @document = attrs[:document]
16
16
  @project = attrs[:project]
17
17
  @stage = attrs[:stage]
18
+ @title_en = attrs[:title_en]
19
+ @title_fr = attrs[:title_fr]
18
20
  @comments = (attrs[:comments] || []).map { |c| c.is_a?(Comment) ? c : Comment.from_hash(c) }
19
21
  end
20
22
 
@@ -29,12 +31,18 @@ module Commenter
29
31
  document: @document,
30
32
  project: @project,
31
33
  stage: @stage,
34
+ title_en: @title_en,
35
+ title_fr: @title_fr,
32
36
  comments: @comments.map(&:to_h)
33
37
  }
34
38
  end
35
39
 
36
40
  def to_yaml_h
37
- stringify_keys(to_h.merge(comments: @comments.map(&:to_yaml_h)))
41
+ hash = to_h.merge(comments: @comments.map(&:to_yaml_h))
42
+ # Remove nil-valued keys for cleaner YAML output
43
+ hash.delete(:title_en) if hash[:title_en].nil?
44
+ hash.delete(:title_fr) if hash[:title_fr].nil?
45
+ stringify_keys(hash)
38
46
  end
39
47
 
40
48
  def self.from_hash(hash)
@@ -16,6 +16,7 @@ module Commenter
16
16
 
17
17
  @title_template = load_liquid_template(title_template_path || default_title_template_path)
18
18
  @body_template = load_liquid_template(body_template_path || default_body_template_path)
19
+ @unique_id_template = load_unique_id_template
19
20
  end
20
21
 
21
22
  def create_issues_from_yaml(yaml_file, options = {})
@@ -70,7 +71,26 @@ module Commenter
70
71
  raise "Template file not found: #{template_path}"
71
72
  end
72
73
 
74
+ def load_unique_id_template
75
+ unique_id_config = @config.dig("github", "templates", "unique_id")
76
+
77
+ if unique_id_config
78
+ # Check if it's a file path or inline template
79
+ if File.exist?(unique_id_config)
80
+ load_liquid_template(unique_id_config)
81
+ else
82
+ Liquid::Template.parse(unique_id_config)
83
+ end
84
+ else
85
+ # Default unique_id pattern: "[STAGE] COMMENT_ID"
86
+ Liquid::Template.parse("[{{ stage | upcase }}] {{ comment_id }}")
87
+ end
88
+ end
89
+
73
90
  def template_variables(comment, comment_sheet)
91
+ # Render unique_id first so it can be used in other templates
92
+ unique_id = render_unique_id(comment, comment_sheet)
93
+
74
94
  {
75
95
  # Comment sheet variables
76
96
  "stage" => comment_sheet.stage || "",
@@ -95,17 +115,30 @@ module Commenter
95
115
  "line_number" => comment.line_number || "",
96
116
 
97
117
  # Computed variables
118
+ "unique_id" => unique_id,
98
119
  "has_observations" => !comment.observations.nil? && !comment.observations.strip.empty?,
99
120
  "has_proposed_change" => !comment.proposed_change.nil? && !comment.proposed_change.strip.empty?,
100
121
  "locality_summary" => format_locality(comment)
101
122
  }
102
123
  end
103
124
 
125
+ def render_unique_id(comment, comment_sheet)
126
+ base_variables = {
127
+ "stage" => comment_sheet.stage || "",
128
+ "document" => comment_sheet.document || "",
129
+ "project" => comment_sheet.project || "",
130
+ "comment_id" => comment.id || "",
131
+ "body" => comment.body || "",
132
+ "type" => comment.type || ""
133
+ }
134
+ @unique_id_template.render(base_variables).strip
135
+ end
136
+
104
137
  def expand_comment_type(type)
105
138
  case type&.downcase
106
- when "ge" then "General"
107
- when "te" then "Technical"
108
- when "ed" then "Editorial"
139
+ when "ge", "general" then "General"
140
+ when "te", "technical" then "Technical"
141
+ when "ed", "editorial" then "Editorial"
109
142
  else type || "Unknown"
110
143
  end
111
144
  end
@@ -119,9 +152,11 @@ module Commenter
119
152
  end
120
153
 
121
154
  def create_issue(comment, comment_sheet, options = {})
122
- # Check if issue already exists
123
- existing_issue = find_existing_issue(comment)
155
+ puts "[GitHubIssueCreator] Creating issue for comment ID: #{comment.id}"
156
+ # Check if issue already exists (stage-aware)
157
+ existing_issue = find_existing_issue(comment, comment_sheet)
124
158
  if existing_issue
159
+ puts "[GitHubIssueCreator] Issue already exists for comment ID: #{comment.id} at stage #{comment_sheet.stage}, skipping creation."
125
160
  return {
126
161
  comment_id: comment.id,
127
162
  status: :skipped,
@@ -139,8 +174,10 @@ module Commenter
139
174
  milestone: determine_milestone(comment, comment_sheet, options)
140
175
  }.compact
141
176
 
177
+ puts "[GitHubIssueCreator] Creating issue with title: #{title}"
142
178
  begin
143
179
  issue = @github_client.create_issue(@repo, title, body, issue_options)
180
+ puts "[GitHubIssueCreator] Issue created successfully: #{issue.html_url}"
144
181
  {
145
182
  comment_id: comment.id,
146
183
  status: :created,
@@ -148,6 +185,7 @@ module Commenter
148
185
  issue_url: issue.html_url
149
186
  }
150
187
  rescue Octokit::Error => e
188
+ puts "[GitHubIssueCreator] Error creating issue for comment ID: #{comment.id} - #{e.message}"
151
189
  {
152
190
  comment_id: comment.id,
153
191
  status: :error,
@@ -157,6 +195,8 @@ module Commenter
157
195
  end
158
196
 
159
197
  def preview_issue(comment, comment_sheet)
198
+ puts "[GitHubIssueCreator] Previewing issue for comment ID: #{comment.id}"
199
+
160
200
  title = @title_template.render(template_variables(comment, comment_sheet))
161
201
  body = @body_template.render(template_variables(comment, comment_sheet))
162
202
 
@@ -170,9 +210,13 @@ module Commenter
170
210
  }
171
211
  end
172
212
 
173
- def find_existing_issue(comment)
174
- # Search for existing issues with the comment ID in the title
175
- query = "repo:#{@repo} in:title #{comment.id}"
213
+ def find_existing_issue(comment, comment_sheet)
214
+ unique_id = render_unique_id(comment, comment_sheet)
215
+ puts "[GitHubIssueCreator] Searching for existing issue with unique_id: #{unique_id}"
216
+
217
+ # Search for existing issues with the unique_id in the title
218
+ # The unique_id is configurable and defaults to "[STAGE] COMMENT_ID"
219
+ query = "repo:#{@repo} in:title \"#{unique_id}\""
176
220
  results = @github_client.search_issues(query)
177
221
  results.items.first
178
222
  rescue Octokit::Error
@@ -212,26 +256,25 @@ module Commenter
212
256
  assignees.compact.uniq
213
257
  end
214
258
 
215
- def determine_milestone(_comment, comment_sheet, options)
216
- # Check for override in options
217
- return resolve_milestone_by_name_or_number(options[:milestone]) if options[:milestone]
218
-
219
- # Check for stage-specific milestone
220
- if comment_sheet.stage
221
- stage_milestone = @config.dig("github", "stage_milestones", comment_sheet.stage)
222
- if stage_milestone
223
- milestone_number = resolve_milestone_by_name_or_number(stage_milestone)
224
- return milestone_number if milestone_number
225
- end
226
- end
259
+ def determine_milestone(_comment, _comment_sheet, _options)
260
+ # # Check for stage-specific milestone
261
+ # if comment_sheet.stage
262
+ # stage_milestone = @config.dig("github", "stage_milestones", comment_sheet.stage)
263
+ # if stage_milestone
264
+ # milestone_number = resolve_milestone_by_name_or_number(stage_milestone)
265
+ # return milestone_number if milestone_number
266
+ # end
267
+ # end
227
268
 
228
269
  # Use configured milestone
229
270
  milestone_config = @config.dig("github", "milestone")
230
271
  return nil unless milestone_config
231
272
 
232
273
  if milestone_config["number"]
274
+ puts "[GitHubIssueCreator] Using milestone number: #{milestone_config["number"]}"
233
275
  milestone_config["number"]
234
276
  elsif milestone_config["name"]
277
+ puts "[GitHubIssueCreator] Using milestone name: #{milestone_config["name"]}"
235
278
  resolve_milestone_by_name_or_number(milestone_config["name"])
236
279
  end
237
280
  end
@@ -246,6 +289,8 @@ module Commenter
246
289
 
247
290
  def find_milestone_by_name(name)
248
291
  milestones = @github_client.milestones(@repo, state: "all")
292
+
293
+ puts "[GitHubIssueCreator] Found #{milestones.size} milestones in repository #{@repo}" if milestones.any?
249
294
  milestone = milestones.find { |m| m.title == name }
250
295
  milestone&.number
251
296
  rescue Octokit::Error
@@ -411,7 +456,8 @@ module Commenter
411
456
  end
412
457
 
413
458
  # Fallback to last comment if configured and no magic comment found
414
- return comments.last.body.strip if @config.dig("github", "retrieval", "fallback_to_last_comment") && !comments.empty?
459
+ return comments.last.body.strip if @config.dig("github", "retrieval",
460
+ "fallback_to_last_comment") && !comments.empty?
415
461
 
416
462
  nil
417
463
  rescue Octokit::Error
@@ -0,0 +1,252 @@
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: normalize_comment_type(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 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
+ def cell_value(row, index)
240
+ return nil if index.nil? || row.length <= index
241
+
242
+ value = row[index]
243
+ value.is_a?(String) && value.strip.empty? ? nil : value
244
+ end
245
+
246
+ def cell_value_str(row, index)
247
+ value = cell_value(row, index)
248
+ value.nil? || value.to_s.strip.empty? ? nil : value.to_s.strip
249
+ end
250
+ end
251
+ end
252
+ end