commenter 0.2.1 → 0.2.2

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,452 @@
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
+ end
20
+
21
+ def create_issues_from_yaml(yaml_file, options = {})
22
+ data = YAML.load_file(yaml_file)
23
+ comment_sheet = CommentSheet.from_hash(data)
24
+
25
+ # Override stage if provided
26
+ comment_sheet.stage = options[:stage] if options[:stage]
27
+
28
+ results = []
29
+ comment_sheet.comments.each do |comment|
30
+ results << if options[:dry_run]
31
+ preview_issue(comment, comment_sheet)
32
+ else
33
+ create_issue(comment, comment_sheet, options)
34
+ end
35
+ end
36
+
37
+ # Update YAML with GitHub info after creation (unless dry run)
38
+ update_yaml_with_github_info(yaml_file, comment_sheet, results, options) unless options[:dry_run]
39
+
40
+ results
41
+ end
42
+
43
+ private
44
+
45
+ def load_config(config_path)
46
+ YAML.load_file(config_path)
47
+ rescue Errno::ENOENT
48
+ raise "Configuration file not found: #{config_path}"
49
+ end
50
+
51
+ def create_github_client
52
+ token = @config.dig("github", "token") || ENV["GITHUB_TOKEN"]
53
+ raise "GitHub token not found. Set GITHUB_TOKEN environment variable or specify in config file." unless token
54
+
55
+ Octokit::Client.new(access_token: token)
56
+ end
57
+
58
+ def default_title_template_path
59
+ File.join(__dir__, "../../data/github_issue_title_template.liquid")
60
+ end
61
+
62
+ def default_body_template_path
63
+ File.join(__dir__, "../../data/github_issue_body_template.liquid")
64
+ end
65
+
66
+ def load_liquid_template(template_path)
67
+ content = File.read(template_path)
68
+ Liquid::Template.parse(content)
69
+ rescue Errno::ENOENT
70
+ raise "Template file not found: #{template_path}"
71
+ end
72
+
73
+ def template_variables(comment, comment_sheet)
74
+ {
75
+ # Comment sheet variables
76
+ "stage" => comment_sheet.stage || "",
77
+ "document" => comment_sheet.document || "",
78
+ "project" => comment_sheet.project || "",
79
+ "date" => comment_sheet.date || "",
80
+ "version" => comment_sheet.version || "",
81
+
82
+ # Comment variables
83
+ "comment_id" => comment.id || "",
84
+ "body" => comment.body || "",
85
+ "type" => comment.type || "",
86
+ "type_full_name" => expand_comment_type(comment.type),
87
+ "comments" => comment.comments || "",
88
+ "proposed_change" => comment.proposed_change || "",
89
+ "observations" => comment.observations || "",
90
+ "brief_summary" => comment.brief_summary,
91
+
92
+ # Locality variables
93
+ "clause" => comment.clause || "",
94
+ "element" => comment.element || "",
95
+ "line_number" => comment.line_number || "",
96
+
97
+ # Computed variables
98
+ "has_observations" => !comment.observations.nil? && !comment.observations.strip.empty?,
99
+ "has_proposed_change" => !comment.proposed_change.nil? && !comment.proposed_change.strip.empty?,
100
+ "locality_summary" => format_locality(comment)
101
+ }
102
+ end
103
+
104
+ def expand_comment_type(type)
105
+ case type&.downcase
106
+ when "ge" then "General"
107
+ when "te" then "Technical"
108
+ when "ed" then "Editorial"
109
+ else type || "Unknown"
110
+ end
111
+ end
112
+
113
+ def format_locality(comment)
114
+ parts = []
115
+ parts << "Clause #{comment.clause}" if comment.clause && !comment.clause.strip.empty?
116
+ parts << comment.element if comment.element && !comment.element.strip.empty?
117
+ parts << "Line #{comment.line_number}" if comment.line_number && !comment.line_number.strip.empty?
118
+ parts.join(", ")
119
+ end
120
+
121
+ def create_issue(comment, comment_sheet, options = {})
122
+ # Check if issue already exists
123
+ existing_issue = find_existing_issue(comment)
124
+ if existing_issue
125
+ return {
126
+ comment_id: comment.id,
127
+ status: :skipped,
128
+ message: "Issue already exists",
129
+ issue_url: existing_issue.html_url
130
+ }
131
+ end
132
+
133
+ title = @title_template.render(template_variables(comment, comment_sheet))
134
+ body = @body_template.render(template_variables(comment, comment_sheet))
135
+
136
+ issue_options = {
137
+ labels: determine_labels(comment, comment_sheet),
138
+ assignees: determine_assignees(comment, comment_sheet, options),
139
+ milestone: determine_milestone(comment, comment_sheet, options)
140
+ }.compact
141
+
142
+ begin
143
+ issue = @github_client.create_issue(@repo, title, body, issue_options)
144
+ {
145
+ comment_id: comment.id,
146
+ status: :created,
147
+ issue_number: issue.number,
148
+ issue_url: issue.html_url
149
+ }
150
+ rescue Octokit::Error => e
151
+ {
152
+ comment_id: comment.id,
153
+ status: :error,
154
+ message: e.message
155
+ }
156
+ end
157
+ end
158
+
159
+ def preview_issue(comment, comment_sheet)
160
+ title = @title_template.render(template_variables(comment, comment_sheet))
161
+ body = @body_template.render(template_variables(comment, comment_sheet))
162
+
163
+ {
164
+ comment_id: comment.id,
165
+ title: title,
166
+ body: body,
167
+ labels: determine_labels(comment, comment_sheet),
168
+ assignees: determine_assignees(comment, comment_sheet, {}),
169
+ milestone: determine_milestone(comment, comment_sheet, {})
170
+ }
171
+ end
172
+
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}"
176
+ results = @github_client.search_issues(query)
177
+ results.items.first
178
+ rescue Octokit::Error
179
+ nil
180
+ end
181
+
182
+ def determine_labels(comment, comment_sheet)
183
+ labels = []
184
+
185
+ # Add default labels
186
+ labels.concat(@config.dig("github", "default_labels") || [])
187
+
188
+ # Add stage-specific labels
189
+ if comment_sheet.stage
190
+ stage_labels = @config.dig("github", "stage_labels", comment_sheet.stage)
191
+ labels.concat(stage_labels) if stage_labels
192
+ end
193
+
194
+ # Add comment type label
195
+ labels << comment.type if comment.type
196
+
197
+ labels.uniq
198
+ end
199
+
200
+ def determine_assignees(_comment, _comment_sheet, options)
201
+ assignees = []
202
+
203
+ # Check for override in options
204
+ if options[:assignee]
205
+ assignees << options[:assignee]
206
+ else
207
+ # Use default assignee from config
208
+ default_assignee = @config.dig("github", "default_assignee")
209
+ assignees << default_assignee if default_assignee
210
+ end
211
+
212
+ assignees.compact.uniq
213
+ end
214
+
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
227
+
228
+ # Use configured milestone
229
+ milestone_config = @config.dig("github", "milestone")
230
+ return nil unless milestone_config
231
+
232
+ if milestone_config["number"]
233
+ milestone_config["number"]
234
+ elsif milestone_config["name"]
235
+ resolve_milestone_by_name_or_number(milestone_config["name"])
236
+ end
237
+ end
238
+
239
+ def resolve_milestone_by_name_or_number(milestone_identifier)
240
+ # If it's a number, return it directly
241
+ return milestone_identifier.to_i if milestone_identifier.to_s.match?(/^\d+$/)
242
+
243
+ # Otherwise, search by name
244
+ find_milestone_by_name(milestone_identifier)
245
+ end
246
+
247
+ def find_milestone_by_name(name)
248
+ milestones = @github_client.milestones(@repo, state: "all")
249
+ milestone = milestones.find { |m| m.title == name }
250
+ milestone&.number
251
+ rescue Octokit::Error
252
+ nil
253
+ end
254
+
255
+ def update_yaml_with_github_info(yaml_file, comment_sheet, results, options)
256
+ # Update comments with GitHub information
257
+ results.each do |result|
258
+ next unless result[:status] == :created
259
+
260
+ comment = comment_sheet.comments.find { |c| c.id == result[:comment_id] }
261
+ next unless comment
262
+
263
+ # Add GitHub information to the comment
264
+ comment.github[:issue_number] = result[:issue_number]
265
+ comment.github[:issue_url] = result[:issue_url]
266
+ comment.github[:status] = "open"
267
+ comment.github[:created_at] = Time.now.utc.iso8601
268
+ end
269
+
270
+ # Write updated YAML
271
+ output_file = options[:output] || yaml_file
272
+ yaml_content = generate_yaml_with_header(comment_sheet.to_yaml_h)
273
+ File.write(output_file, yaml_content)
274
+ end
275
+
276
+ def generate_yaml_with_header(data)
277
+ header = "# yaml-language-server: $schema=schema/iso_comment_2012-03.yaml\n\n"
278
+ header + data.to_yaml
279
+ end
280
+ end
281
+
282
+ class GitHubIssueRetriever
283
+ def initialize(config_path)
284
+ @config = load_config(config_path)
285
+ @github_client = create_github_client
286
+ @repo = @config.dig("github", "repository")
287
+
288
+ raise "GitHub repository not specified in config" unless @repo
289
+ end
290
+
291
+ def retrieve_observations_from_yaml(yaml_file, options = {})
292
+ data = YAML.load_file(yaml_file)
293
+ comment_sheet = CommentSheet.from_hash(data)
294
+
295
+ results = []
296
+ comment_sheet.comments.each do |comment|
297
+ next unless comment.has_github_issue?
298
+
299
+ result = if options[:dry_run]
300
+ preview_observation_retrieval(comment, options)
301
+ else
302
+ retrieve_observation(comment, options)
303
+ end
304
+ results << result
305
+ end
306
+
307
+ # Update YAML with observations (unless dry run)
308
+ update_yaml_with_observations(yaml_file, comment_sheet, options) unless options[:dry_run]
309
+
310
+ results
311
+ end
312
+
313
+ private
314
+
315
+ def load_config(config_path)
316
+ YAML.load_file(config_path)
317
+ rescue Errno::ENOENT
318
+ raise "Configuration file not found: #{config_path}"
319
+ end
320
+
321
+ def create_github_client
322
+ token = @config.dig("github", "token") || ENV["GITHUB_TOKEN"]
323
+ raise "GitHub token not found. Set GITHUB_TOKEN environment variable or specify in config file." unless token
324
+
325
+ Octokit::Client.new(access_token: token)
326
+ end
327
+
328
+ def retrieve_observation(comment, options)
329
+ issue_number = comment.github_issue_number
330
+
331
+ begin
332
+ issue = @github_client.issue(@repo, issue_number)
333
+
334
+ # Skip open issues unless explicitly included
335
+ if issue.state == "open" && !options[:include_open]
336
+ return {
337
+ comment_id: comment.id,
338
+ issue_number: issue_number,
339
+ status: :skipped,
340
+ message: "Issue is still open"
341
+ }
342
+ end
343
+
344
+ # Extract observation from issue comments
345
+ observation = extract_observation_from_issue(issue_number)
346
+
347
+ if observation
348
+ # Update comment with observation and current status
349
+ comment.observations = observation
350
+ comment.github[:status] = issue.state
351
+ comment.github[:updated_at] = Time.now.utc.iso8601
352
+
353
+ {
354
+ comment_id: comment.id,
355
+ issue_number: issue_number,
356
+ status: :retrieved,
357
+ observation: observation
358
+ }
359
+ else
360
+ {
361
+ comment_id: comment.id,
362
+ issue_number: issue_number,
363
+ status: :skipped,
364
+ message: "No observation found in issue"
365
+ }
366
+ end
367
+ rescue Octokit::Error => e
368
+ {
369
+ comment_id: comment.id,
370
+ issue_number: issue_number,
371
+ status: :error,
372
+ message: e.message
373
+ }
374
+ end
375
+ end
376
+
377
+ def preview_observation_retrieval(comment, _options)
378
+ issue_number = comment.github_issue_number
379
+
380
+ begin
381
+ issue = @github_client.issue(@repo, issue_number)
382
+ observation = extract_observation_from_issue(issue_number)
383
+
384
+ {
385
+ comment_id: comment.id,
386
+ issue_number: issue_number,
387
+ status: issue.state,
388
+ observation: observation
389
+ }
390
+ rescue Octokit::Error => e
391
+ {
392
+ comment_id: comment.id,
393
+ issue_number: issue_number,
394
+ status: :error,
395
+ message: e.message
396
+ }
397
+ end
398
+ end
399
+
400
+ def extract_observation_from_issue(issue_number)
401
+ comments = @github_client.issue_comments(@repo, issue_number)
402
+
403
+ # Look for magic comments with observation markers
404
+ observation_markers = @config.dig("github", "retrieval", "observation_markers") ||
405
+ ["**OBSERVATION:**", "**COMMENTER OBSERVATION:**"]
406
+
407
+ # Search comments in reverse order (newest first)
408
+ comments.reverse_each do |comment|
409
+ observation = parse_observation_from_comment(comment.body, observation_markers)
410
+ return observation if observation
411
+ end
412
+
413
+ # 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?
415
+
416
+ nil
417
+ rescue Octokit::Error
418
+ nil
419
+ end
420
+
421
+ def parse_observation_from_comment(comment_body, markers)
422
+ markers.each do |marker|
423
+ # Look for markdown blockquote with the marker
424
+ pattern = /^>\s*#{Regexp.escape(marker)}\s*\n((?:^>.*\n?)*)/m
425
+ match = comment_body.match(pattern)
426
+
427
+ next unless match
428
+
429
+ # Extract the blockquote content and clean it up
430
+ observation = match[1]
431
+ .split("\n")
432
+ .map { |line| line.sub(/^>\s?/, "") }
433
+ .join("\n")
434
+ .strip
435
+ return observation unless observation.empty?
436
+ end
437
+
438
+ nil
439
+ end
440
+
441
+ def update_yaml_with_observations(yaml_file, comment_sheet, options)
442
+ output_file = options[:output] || yaml_file
443
+ yaml_content = generate_yaml_with_header(comment_sheet.to_yaml_h)
444
+ File.write(output_file, yaml_content)
445
+ end
446
+
447
+ def generate_yaml_with_header(data)
448
+ header = "# yaml-language-server: $schema=schema/iso_comment_2012-03.yaml\n\n"
449
+ header + data.to_yaml
450
+ end
451
+ end
452
+ 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.2"
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"]