commenter 0.2.1 → 0.2.3

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.
@@ -0,0 +1,498 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "octokit"
4
+ require "liquid"
5
+ require "yaml"
6
+ require "dotenv/load"
7
+
8
+ module Commenter
9
+ class GitHubIssueCreator
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
16
+
17
+ @title_template = load_liquid_template(title_template_path || default_title_template_path)
18
+ @body_template = load_liquid_template(body_template_path || default_body_template_path)
19
+ @unique_id_template = load_unique_id_template
20
+ end
21
+
22
+ def create_issues_from_yaml(yaml_file, options = {})
23
+ data = YAML.load_file(yaml_file)
24
+ comment_sheet = CommentSheet.from_hash(data)
25
+
26
+ # Override stage if provided
27
+ comment_sheet.stage = options[:stage] if options[:stage]
28
+
29
+ results = []
30
+ comment_sheet.comments.each do |comment|
31
+ results << if options[:dry_run]
32
+ preview_issue(comment, comment_sheet)
33
+ else
34
+ create_issue(comment, comment_sheet, options)
35
+ end
36
+ end
37
+
38
+ # Update YAML with GitHub info after creation (unless dry run)
39
+ update_yaml_with_github_info(yaml_file, comment_sheet, results, options) unless options[:dry_run]
40
+
41
+ results
42
+ end
43
+
44
+ private
45
+
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
+ def default_title_template_path
60
+ File.join(__dir__, "../../data/github_issue_title_template.liquid")
61
+ end
62
+
63
+ def default_body_template_path
64
+ File.join(__dir__, "../../data/github_issue_body_template.liquid")
65
+ end
66
+
67
+ def load_liquid_template(template_path)
68
+ content = File.read(template_path)
69
+ Liquid::Template.parse(content)
70
+ rescue Errno::ENOENT
71
+ raise "Template file not found: #{template_path}"
72
+ end
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
+
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
+
94
+ {
95
+ # Comment sheet variables
96
+ "stage" => comment_sheet.stage || "",
97
+ "document" => comment_sheet.document || "",
98
+ "project" => comment_sheet.project || "",
99
+ "date" => comment_sheet.date || "",
100
+ "version" => comment_sheet.version || "",
101
+
102
+ # Comment variables
103
+ "comment_id" => comment.id || "",
104
+ "body" => comment.body || "",
105
+ "type" => comment.type || "",
106
+ "type_full_name" => expand_comment_type(comment.type),
107
+ "comments" => comment.comments || "",
108
+ "proposed_change" => comment.proposed_change || "",
109
+ "observations" => comment.observations || "",
110
+ "brief_summary" => comment.brief_summary,
111
+
112
+ # Locality variables
113
+ "clause" => comment.clause || "",
114
+ "element" => comment.element || "",
115
+ "line_number" => comment.line_number || "",
116
+
117
+ # Computed variables
118
+ "unique_id" => unique_id,
119
+ "has_observations" => !comment.observations.nil? && !comment.observations.strip.empty?,
120
+ "has_proposed_change" => !comment.proposed_change.nil? && !comment.proposed_change.strip.empty?,
121
+ "locality_summary" => format_locality(comment)
122
+ }
123
+ end
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
+
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
+ def format_locality(comment)
147
+ parts = []
148
+ parts << "Clause #{comment.clause}" if comment.clause && !comment.clause.strip.empty?
149
+ parts << comment.element if comment.element && !comment.element.strip.empty?
150
+ parts << "Line #{comment.line_number}" if comment.line_number && !comment.line_number.strip.empty?
151
+ parts.join(", ")
152
+ end
153
+
154
+ def create_issue(comment, comment_sheet, options = {})
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)
158
+ if existing_issue
159
+ puts "[GitHubIssueCreator] Issue already exists for comment ID: #{comment.id} at stage #{comment_sheet.stage}, skipping creation."
160
+ return {
161
+ comment_id: comment.id,
162
+ status: :skipped,
163
+ message: "Issue already exists",
164
+ issue_url: existing_issue.html_url
165
+ }
166
+ end
167
+
168
+ title = @title_template.render(template_variables(comment, comment_sheet))
169
+ body = @body_template.render(template_variables(comment, comment_sheet))
170
+
171
+ issue_options = {
172
+ labels: determine_labels(comment, comment_sheet),
173
+ assignees: determine_assignees(comment, comment_sheet, options),
174
+ milestone: determine_milestone(comment, comment_sheet, options)
175
+ }.compact
176
+
177
+ puts "[GitHubIssueCreator] Creating issue with title: #{title}"
178
+ begin
179
+ issue = @github_client.create_issue(@repo, title, body, issue_options)
180
+ puts "[GitHubIssueCreator] Issue created successfully: #{issue.html_url}"
181
+ {
182
+ comment_id: comment.id,
183
+ status: :created,
184
+ issue_number: issue.number,
185
+ issue_url: issue.html_url
186
+ }
187
+ rescue Octokit::Error => e
188
+ puts "[GitHubIssueCreator] Error creating issue for comment ID: #{comment.id} - #{e.message}"
189
+ {
190
+ comment_id: comment.id,
191
+ status: :error,
192
+ message: e.message
193
+ }
194
+ end
195
+ end
196
+
197
+ def preview_issue(comment, comment_sheet)
198
+ puts "[GitHubIssueCreator] Previewing issue for comment ID: #{comment.id}"
199
+
200
+ title = @title_template.render(template_variables(comment, comment_sheet))
201
+ body = @body_template.render(template_variables(comment, comment_sheet))
202
+
203
+ {
204
+ comment_id: comment.id,
205
+ title: title,
206
+ body: body,
207
+ labels: determine_labels(comment, comment_sheet),
208
+ assignees: determine_assignees(comment, comment_sheet, {}),
209
+ milestone: determine_milestone(comment, comment_sheet, {})
210
+ }
211
+ end
212
+
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}\""
220
+ results = @github_client.search_issues(query)
221
+ results.items.first
222
+ rescue Octokit::Error
223
+ nil
224
+ end
225
+
226
+ def determine_labels(comment, comment_sheet)
227
+ labels = []
228
+
229
+ # Add default labels
230
+ labels.concat(@config.dig("github", "default_labels") || [])
231
+
232
+ # Add stage-specific labels
233
+ if comment_sheet.stage
234
+ stage_labels = @config.dig("github", "stage_labels", comment_sheet.stage)
235
+ labels.concat(stage_labels) if stage_labels
236
+ end
237
+
238
+ # Add comment type label
239
+ labels << comment.type if comment.type
240
+
241
+ labels.uniq
242
+ end
243
+
244
+ def determine_assignees(_comment, _comment_sheet, options)
245
+ assignees = []
246
+
247
+ # Check for override in options
248
+ if options[:assignee]
249
+ assignees << options[:assignee]
250
+ else
251
+ # Use default assignee from config
252
+ default_assignee = @config.dig("github", "default_assignee")
253
+ assignees << default_assignee if default_assignee
254
+ end
255
+
256
+ assignees.compact.uniq
257
+ end
258
+
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
268
+
269
+ # Use configured milestone
270
+ milestone_config = @config.dig("github", "milestone")
271
+ return nil unless milestone_config
272
+
273
+ if milestone_config["number"]
274
+ puts "[GitHubIssueCreator] Using milestone number: #{milestone_config["number"]}"
275
+ milestone_config["number"]
276
+ elsif milestone_config["name"]
277
+ puts "[GitHubIssueCreator] Using milestone name: #{milestone_config["name"]}"
278
+ resolve_milestone_by_name_or_number(milestone_config["name"])
279
+ end
280
+ end
281
+
282
+ def resolve_milestone_by_name_or_number(milestone_identifier)
283
+ # If it's a number, return it directly
284
+ return milestone_identifier.to_i if milestone_identifier.to_s.match?(/^\d+$/)
285
+
286
+ # Otherwise, search by name
287
+ find_milestone_by_name(milestone_identifier)
288
+ end
289
+
290
+ def find_milestone_by_name(name)
291
+ milestones = @github_client.milestones(@repo, state: "all")
292
+
293
+ puts "[GitHubIssueCreator] Found #{milestones.size} milestones in repository #{@repo}" if milestones.any?
294
+ milestone = milestones.find { |m| m.title == name }
295
+ milestone&.number
296
+ rescue Octokit::Error
297
+ nil
298
+ end
299
+
300
+ def update_yaml_with_github_info(yaml_file, comment_sheet, results, options)
301
+ # Update comments with GitHub information
302
+ results.each do |result|
303
+ next unless result[:status] == :created
304
+
305
+ comment = comment_sheet.comments.find { |c| c.id == result[:comment_id] }
306
+ next unless comment
307
+
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
313
+ end
314
+
315
+ # Write updated YAML
316
+ 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
324
+ end
325
+ end
326
+
327
+ class GitHubIssueRetriever
328
+ 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
334
+ end
335
+
336
+ def retrieve_observations_from_yaml(yaml_file, options = {})
337
+ data = YAML.load_file(yaml_file)
338
+ comment_sheet = CommentSheet.from_hash(data)
339
+
340
+ results = []
341
+ comment_sheet.comments.each do |comment|
342
+ next unless comment.has_github_issue?
343
+
344
+ result = if options[:dry_run]
345
+ preview_observation_retrieval(comment, options)
346
+ else
347
+ retrieve_observation(comment, options)
348
+ end
349
+ results << result
350
+ end
351
+
352
+ # Update YAML with observations (unless dry run)
353
+ update_yaml_with_observations(yaml_file, comment_sheet, options) unless options[:dry_run]
354
+
355
+ results
356
+ end
357
+
358
+ private
359
+
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
+ def retrieve_observation(comment, options)
374
+ issue_number = comment.github_issue_number
375
+
376
+ begin
377
+ issue = @github_client.issue(@repo, issue_number)
378
+
379
+ # Skip open issues unless explicitly included
380
+ if issue.state == "open" && !options[:include_open]
381
+ return {
382
+ comment_id: comment.id,
383
+ issue_number: issue_number,
384
+ status: :skipped,
385
+ message: "Issue is still open"
386
+ }
387
+ end
388
+
389
+ # Extract observation from issue comments
390
+ observation = extract_observation_from_issue(issue_number)
391
+
392
+ if observation
393
+ # Update comment with observation and current status
394
+ comment.observations = observation
395
+ comment.github[:status] = issue.state
396
+ comment.github[:updated_at] = Time.now.utc.iso8601
397
+
398
+ {
399
+ comment_id: comment.id,
400
+ issue_number: issue_number,
401
+ status: :retrieved,
402
+ observation: observation
403
+ }
404
+ else
405
+ {
406
+ comment_id: comment.id,
407
+ issue_number: issue_number,
408
+ status: :skipped,
409
+ message: "No observation found in issue"
410
+ }
411
+ end
412
+ rescue Octokit::Error => e
413
+ {
414
+ comment_id: comment.id,
415
+ issue_number: issue_number,
416
+ status: :error,
417
+ message: e.message
418
+ }
419
+ end
420
+ end
421
+
422
+ def preview_observation_retrieval(comment, _options)
423
+ issue_number = comment.github_issue_number
424
+
425
+ begin
426
+ issue = @github_client.issue(@repo, issue_number)
427
+ observation = extract_observation_from_issue(issue_number)
428
+
429
+ {
430
+ comment_id: comment.id,
431
+ issue_number: issue_number,
432
+ status: issue.state,
433
+ observation: observation
434
+ }
435
+ rescue Octokit::Error => e
436
+ {
437
+ comment_id: comment.id,
438
+ issue_number: issue_number,
439
+ status: :error,
440
+ message: e.message
441
+ }
442
+ end
443
+ end
444
+
445
+ def extract_observation_from_issue(issue_number)
446
+ comments = @github_client.issue_comments(@repo, issue_number)
447
+
448
+ # Look for magic comments with observation markers
449
+ observation_markers = @config.dig("github", "retrieval", "observation_markers") ||
450
+ ["**OBSERVATION:**", "**COMMENTER OBSERVATION:**"]
451
+
452
+ # Search comments in reverse order (newest first)
453
+ comments.reverse_each do |comment|
454
+ observation = parse_observation_from_comment(comment.body, observation_markers)
455
+ return observation if observation
456
+ end
457
+
458
+ # Fallback to last comment if configured and no magic comment found
459
+ return comments.last.body.strip if @config.dig("github", "retrieval",
460
+ "fallback_to_last_comment") && !comments.empty?
461
+
462
+ nil
463
+ rescue Octokit::Error
464
+ nil
465
+ end
466
+
467
+ def parse_observation_from_comment(comment_body, markers)
468
+ markers.each do |marker|
469
+ # Look for markdown blockquote with the marker
470
+ pattern = /^>\s*#{Regexp.escape(marker)}\s*\n((?:^>.*\n?)*)/m
471
+ match = comment_body.match(pattern)
472
+
473
+ next unless match
474
+
475
+ # Extract the blockquote content and clean it up
476
+ observation = match[1]
477
+ .split("\n")
478
+ .map { |line| line.sub(/^>\s?/, "") }
479
+ .join("\n")
480
+ .strip
481
+ return observation unless observation.empty?
482
+ end
483
+
484
+ nil
485
+ end
486
+
487
+ def update_yaml_with_observations(yaml_file, comment_sheet, options)
488
+ 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
496
+ end
497
+ end
498
+ end
@@ -36,9 +36,9 @@ module Commenter
36
36
  id: id,
37
37
  body: body,
38
38
  locality: {
39
- line_number: cells[1]&.empty? ? nil : cells[1],
40
- clause: cells[2]&.empty? ? nil : cells[2],
41
- element: cells[3]&.empty? ? nil : cells[3]
39
+ line_number: cells[1] && cells[1].empty? ? nil : cells[1],
40
+ clause: cells[2] && cells[2].empty? ? nil : cells[2],
41
+ element: cells[3] && cells[3].empty? ? nil : cells[3]
42
42
  },
43
43
  type: cells[4] || "",
44
44
  comments: cells[5] || "",
@@ -47,7 +47,7 @@ module Commenter
47
47
 
48
48
  # Handle observations column
49
49
  unless options[:exclude_observations]
50
- comment_attrs[:observations] = cells[7]&.empty? ? nil : cells[7]
50
+ comment_attrs[:observations] = cells[7] && cells[7].empty? ? nil : cells[7]
51
51
  end
52
52
 
53
53
  comments << Comment.new(comment_attrs)
@@ -71,9 +71,13 @@ module Commenter
71
71
  # Try to extract metadata from document properties first
72
72
  begin
73
73
  if doc.respond_to?(:created) && doc.created
74
- metadata[:date] = doc.created.strftime("%Y-%m-%d") rescue nil
74
+ metadata[:date] = begin
75
+ doc.created.strftime("%Y-%m-%d")
76
+ rescue StandardError
77
+ nil
78
+ end
75
79
  end
76
- rescue => e
80
+ rescue StandardError
77
81
  # Ignore errors accessing document properties
78
82
  end
79
83
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commenter
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.3"
5
5
  end
data/lib/commenter.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "commenter/comment"
5
5
  require_relative "commenter/comment_sheet"
6
6
  require_relative "commenter/parser"
7
7
  require_relative "commenter/filler"
8
+ require_relative "commenter/github_integration"
8
9
 
9
10
  module Commenter
10
11
  class Error < StandardError; end
@@ -9,6 +9,20 @@ properties:
9
9
  type: string
10
10
  const: "2012-03"
11
11
  description: Version of the ISO commenting template
12
+ date:
13
+ type: ["string", "null"]
14
+ description: Date of the comment sheet
15
+ format: date
16
+ document:
17
+ type: ["string", "null"]
18
+ description: Document identifier being reviewed
19
+ project:
20
+ type: ["string", "null"]
21
+ description: Project name
22
+ stage:
23
+ type: ["string", "null"]
24
+ description: Approval stage (WD/CD/DIS/FDIS/PRF/PUB)
25
+ enum: [null, "WD", "CD", "DIS", "FDIS", "PRF", "PUB"]
12
26
  comments:
13
27
  type: array
14
28
  description: Array of comment entries from the ISO comment sheet
@@ -65,5 +79,29 @@ properties:
65
79
  observations:
66
80
  type: ["string", "null"]
67
81
  description: "Observations of the Secretariat (optional)"
82
+ github:
83
+ type: ["object", "null"]
84
+ description: "GitHub integration information"
85
+ properties:
86
+ issue_number:
87
+ type: integer
88
+ description: "GitHub issue number"
89
+ issue_url:
90
+ type: string
91
+ format: uri
92
+ description: "GitHub issue URL"
93
+ status:
94
+ type: string
95
+ enum: ["open", "closed"]
96
+ description: "GitHub issue status"
97
+ created_at:
98
+ type: string
99
+ format: date-time
100
+ description: "Issue creation timestamp"
101
+ updated_at:
102
+ type: string
103
+ format: date-time
104
+ description: "Issue last update timestamp"
105
+ required: ["issue_number", "issue_url", "status"]
68
106
  required: ["id", "body", "locality", "type", "comments", "proposed_change"]
69
107
  required: ["version", "comments"]