commenter 0.6.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e569c1e1d181c27078d97e4bcaa8a63c54b055aa7aff5dff88646be1237c6a65
4
- data.tar.gz: 5d156b1e49c82c9b8002e47f7ab870d98e133622b4830768e02b9bf32c2db993
3
+ metadata.gz: 116bd5694762b20a730aa2d67d9950e3acedecf5e0e5d2f95a6d183d376c0e97
4
+ data.tar.gz: d1c34482394b44f5195a91268eb3b46f4db58c986afc214a841d74b908f98edb
5
5
  SHA512:
6
- metadata.gz: 66daebbb66df3c881828bccd0a36c8cb0e1c524d74c19efef579770f707a366c2f48a51b2871041dd3519db0b26ad4431f0065395b02d85685f08eed9862e39e
7
- data.tar.gz: 6ea9b41315c94d87c8356350be9b55235d1dfd07e341bbbc133a6feea27b807f36ac9df05bc32430ed314c73c5b5c8a25d9e1ff5066beb0f36f90032c3b9330a
6
+ metadata.gz: 1d755c6bfefe119528e4ddb9e65033263f5c03550d972ae29bd62aaaea8df3fa571439305f80429eefb017856d387e5dde23c18a744ecdfb03d2f77d6a6e486e
7
+ data.tar.gz: 6d297683a681dc1918be0db3a5dd63aded21fca0fce0a63799b36e1342d60f927b9690ca9757c8935c74c530edc3205acb7b510e4930360a7f4a99d595a865fc
data/.rubocop_todo.yml CHANGED
@@ -30,7 +30,7 @@ Metrics/BlockLength:
30
30
  # Offense count: 5
31
31
  # Configuration parameters: CountComments, CountAsOne.
32
32
  Metrics/ClassLength:
33
- Max: 231
33
+ Max: 270
34
34
 
35
35
  # Offense count: 8
36
36
  # Configuration parameters: AllowedMethods, AllowedPatterns.
data/CLAUDE.md CHANGED
@@ -49,8 +49,8 @@ Core flow: `Parser` → `CommentSheet` (metadata + `Comment` objects) → YAML /
49
49
  - `Parser::OsdXlsxParser` — auto-detects two OSD export variants by header row ("resolved" 17-col starting `Comment ID`; "unresolved" 15-col starting `User name`), extracts metadata (date/reference/stage/titles) from header rows 1–2, maps columns by header name into `Comment` attributes, and synthesizes `observations` from `resolution_status` + `motivation`.
50
50
  - `Parser::TrackChangeDocxParser` — redline DOCX import: streams `word/document.xml` with Nokogiri XML::Reader (redlines can exceed 100 MB), captures `w:ins`/`w:del`/`w:moveFrom`/`w:moveTo` as comment entries (`proposed_change` renders the change itself, e.g. `Insert: "text"`; clause resolved from the nearest preceding heading including sub-clauses; element resolved from Table/Figure/Formula/NOTE references in the same clause; self-closing paragraph-mark markers skipped), plus reviewer `w:comment` threads as `-CNNN` remark entries (remark verbatim in `comments`, instruction reworded in `proposed_change`, empty `observations` for the owner). Stamps `observations` from the `observations`/`accept_all` options.
51
51
  - `Commenter::Filler` — writes comments into the DOCX template table; maps observation status text (`accept(ed)?`, `noted`, `reject(ed)?`, …) to shading colors.
52
- - GitHub integration (`lib/commenter/github_integration.rb`) — two classes with parallel structure: `GitHubIssueCreator` (`github-create`) and `GitHubIssueRetriever` (`github-retrieve`). Each loads its own config YAML and Octokit client, and rewrites the comments YAML in place after the run. Duplicate detection searches issue titles for a unique ID rendered from a configurable Liquid `unique_id` template (stage-aware by default: `[DIS] GB-001`), so the same comment ID at different ballot stages creates separate issues.
53
- - `Commenter::Cli` (`lib/commenter/cli.rb`) — Thor CLI with subcommands `import`, `fill`, `github-create`, `github-retrieve`.
52
+ - GitHub integration — `GitHubIssueCreator` (`github-create`, `lib/commenter/github_integration.rb`), `GitHubIssueRetriever` (`github-retrieve`, `lib/commenter/github_issue_retriever.rb`), and `GitHubSync` (`github-sync`, subclass of the creator). Each loads its own config YAML and Octokit client, and rewrites the comments YAML in place after the run. Duplicate detection verifies issue numbers already recorded in the YAML, then searches issue bodies for a deterministic `urn:commenter:` marker appended to every created issue, then falls back to a title search over a configurable Liquid `unique_id` template (stage-aware by default: `[DIS] GB-001`). The same comment ID at different ballot stages creates separate issues. github-retrieve fills observations from open and closed issues alike.
53
+ - `Commenter::Cli` (`lib/commenter/cli.rb`) — Thor CLI with subcommands `import`, `merge`, `stats`, `diff`, `fill`, `export`, `github-create`, `github-sync`, `github-retrieve`.
54
54
 
55
55
  ### Schemas
56
56
 
data/README.adoc CHANGED
@@ -294,7 +294,7 @@ Options:
294
294
  The commenter gem provides comprehensive GitHub integration for collaborative comment review and tracking. The workflow consists of two main commands:
295
295
 
296
296
  * `github-create` - Creates GitHub issues from comments and tracks them in YAML
297
- * `github-retrieve` - Retrieves final observations from closed GitHub issues
297
+ * `github-retrieve` - Retrieves observations recorded in GitHub issues
298
298
 
299
299
  ==== Creating GitHub issues from comments
300
300
 
@@ -431,6 +431,18 @@ detection uses a **configurable unique identifier** that is:
431
431
  This ensures that the search pattern always matches what appears in the title,
432
432
  preventing synchronization issues.
433
433
 
434
+ Every created issue body also carries a deterministic marker line, e.g.
435
+ +
436
+ [source]
437
+ ----
438
+ `urn:commenter:owner/repo:dis:GB-001`
439
+ ----
440
+ +
441
+ Duplicate detection searches the issue bodies for this marker first — exact
442
+ and template-independent — and falls back to the title search for issues
443
+ created before the marker existed. Comment numbers already recorded in the
444
+ comments YAML are verified with a direct read before any search runs.
445
+
434
446
  ===== Unique ID template
435
447
 
436
448
  The unique identifier is defined by the `unique_id` template in your configuration:
@@ -534,7 +546,7 @@ $ commenter github-retrieve --config github_config.yaml comments.yaml
534
546
  This command:
535
547
 
536
548
  * Reads GitHub issue information from the YAML file (no searching required)
537
- * Fetches observations from closed GitHub issues only
549
+ * Fills in an observation whenever the issue carries one, open or closed
538
550
  * Updates the same YAML file with extracted observations
539
551
  * Preserves all existing comment data
540
552
 
@@ -561,7 +573,7 @@ Alternative shorter syntax:
561
573
 
562
574
  * `-c, --config FILE` - GitHub configuration YAML file (required)
563
575
  * `-o, --output FILE` - Output YAML file (default: update original)
564
- * `--include-open` - Include observations from open issues (not recommended)
576
+ * `--include-open` - (Deprecated) observations are now filled from open issues too
565
577
  * `--dry-run` - Preview observations without updating YAML
566
578
 
567
579
  ===== Examples
data/lib/commenter/cli.rb CHANGED
@@ -157,46 +157,8 @@ module Commenter
157
157
 
158
158
  results = creator.create_issues_from_yaml(input_yaml, github_options)
159
159
 
160
- if options[:dry_run]
161
- puts "DRY RUN - Preview of issues to be created:"
162
- puts "=" * 50
163
- results.each do |result|
164
- puts "\nComment ID: #{result[:comment_id]}"
165
- puts "Title: #{result[:title]}"
166
- puts "Labels: #{result[:labels].join(", ")}" if result[:labels]&.any?
167
- puts "Assignees: #{result[:assignees].join(", ")}" if result[:assignees]&.any?
168
- puts "Milestone: #{result[:milestone]}" if result[:milestone]
169
- puts "\nBody preview (first 200 chars):"
170
- puts result[:body][0...200] + (result[:body].length > 200 ? "..." : "")
171
- puts "-" * 30
172
- end
173
- else
174
- puts "GitHub issue creation results:"
175
- puts "=" * 40
176
-
177
- created_count = 0
178
- skipped_count = 0
179
- error_count = 0
180
-
181
- results.each do |result|
182
- case result[:status]
183
- when :created
184
- created_count += 1
185
- puts "✓ #{result[:comment_id]}: Created issue ##{result[:issue_number]}"
186
- puts " URL: #{result[:issue_url]}"
187
- when :skipped
188
- skipped_count += 1
189
- puts "- #{result[:comment_id]}: Skipped (#{result[:message]})"
190
- puts " URL: #{result[:issue_url]}" if result[:issue_url]
191
- when :error
192
- error_count += 1
193
- puts "✗ #{result[:comment_id]}: Error - #{result[:message]}"
194
- end
195
- end
196
-
197
- puts "\nSummary:"
198
- puts "Created: #{created_count}, Skipped: #{skipped_count}, Errors: #{error_count}"
199
- end
160
+ error_count = report_issue_results(results, options[:dry_run])
161
+ exit 1 if error_count.to_i.positive?
200
162
  rescue StandardError => e
201
163
  puts "Error: #{e.message}"
202
164
  exit 1
@@ -341,5 +303,47 @@ module Commenter
341
303
  FileUtils.cp(schema_source, schema_target) unless File.expand_path(schema_source) == File.expand_path(schema_target)
342
304
  schema_target
343
305
  end
306
+
307
+ def report_issue_results(results, dry_run)
308
+ if dry_run
309
+ puts "DRY RUN - Preview of issues to be created:"
310
+ puts "=" * 50
311
+ results.each do |result|
312
+ puts "\nComment ID: #{result[:comment_id]}"
313
+ puts "Title: #{result[:title]}"
314
+ puts "Labels: #{result[:labels].join(", ")}" if result[:labels]&.any?
315
+ puts "Assignees: #{result[:assignees].join(", ")}" if result[:assignees]&.any?
316
+ puts "Milestone: #{result[:milestone]}" if result[:milestone]
317
+ puts "\nBody preview (first 200 chars):"
318
+ puts result[:body][0...200] + (result[:body].length > 200 ? "..." : "")
319
+ puts "-" * 30
320
+ end
321
+ return nil
322
+ end
323
+
324
+ puts "GitHub issue creation results:"
325
+ puts "=" * 40
326
+
327
+ counts = Hash.new(0)
328
+ results.each do |result|
329
+ case result[:status]
330
+ when :created
331
+ counts[:created] += 1
332
+ puts "✓ #{result[:comment_id]}: Created issue ##{result[:issue_number]}"
333
+ puts " URL: #{result[:issue_url]}"
334
+ when :skipped
335
+ counts[:skipped] += 1
336
+ puts "- #{result[:comment_id]}: Skipped (#{result[:message]})"
337
+ puts " URL: #{result[:issue_url]}" if result[:issue_url]
338
+ when :error
339
+ counts[:error] += 1
340
+ puts "✗ #{result[:comment_id]}: Error - #{result[:message]}"
341
+ end
342
+ end
343
+
344
+ puts "\nSummary:"
345
+ puts "Created: #{counts[:created]}, Skipped: #{counts[:skipped]}, Errors: #{counts[:error]}"
346
+ counts[:error]
347
+ end
344
348
  end
345
349
  end
@@ -35,5 +35,12 @@ module Commenter
35
35
  def display_name(type)
36
36
  DISPLAY_NAMES.fetch(type.to_s.strip.downcase) { type || "Unknown" }
37
37
  end
38
+
39
+ # True when the value is exactly one recognized comment type, in code or
40
+ # full-name spelling. Combined or free-form values ("ge/te") are not.
41
+ def known?(type)
42
+ downcased = type.to_s.strip.downcase
43
+ FULL_NAMES.key?(downcased) || FULL_NAMES.value?(downcased)
44
+ end
38
45
  end
39
46
  end
@@ -7,10 +7,15 @@ require "dotenv/load"
7
7
 
8
8
  module Commenter
9
9
  class GitHubIssueCreator
10
- def initialize(config_path, title_template_path = nil, body_template_path = nil)
10
+ THROTTLE_SECONDS = 1
11
+ RATE_LIMIT_RETRY_DELAY = 60
12
+ RATE_LIMIT_MAX_RETRIES = 3
13
+ MARKER_PREFIX = "urn:commenter"
14
+
15
+ def initialize(config_path, title_template_path = nil, body_template_path = nil, client: nil)
11
16
  @session = GitHubSession.new(config_path)
12
17
  @config = @session.config
13
- @github_client = @session.client
18
+ @github_client = client || @session.client
14
19
  @repo = @session.repo
15
20
 
16
21
  @title_template = load_liquid_template(title_template_path || default_title_template_path)
@@ -129,30 +134,36 @@ module Commenter
129
134
 
130
135
  def create_issue(comment, comment_sheet, options = {})
131
136
  puts "[GitHubIssueCreator] Creating issue for comment ID: #{comment.id}"
132
- # Check if issue already exists (stage-aware)
133
- existing_issue = find_existing_issue(comment, comment_sheet)
134
- if existing_issue
135
- puts "[GitHubIssueCreator] Issue already exists for comment ID: #{comment.id} at stage #{comment_sheet.stage}, skipping creation."
136
- return {
137
- comment_id: comment.id,
138
- status: :skipped,
139
- message: "Issue already exists",
140
- issue_url: existing_issue.html_url
141
- }
142
- end
137
+ begin
138
+ # The recorded issue number is verified with a consistent GET; the
139
+ # title search alone cannot see issues GitHub has not indexed yet.
140
+ existing_issue = recorded_issue(comment, comment_sheet) ||
141
+ find_existing_issue(comment, comment_sheet)
142
+ if existing_issue
143
+ puts "[GitHubIssueCreator] Issue already exists for comment ID: #{comment.id} " \
144
+ "at stage #{comment_sheet.stage}, skipping creation."
145
+ return {
146
+ comment_id: comment.id,
147
+ status: :skipped,
148
+ message: "Issue already exists",
149
+ issue_number: existing_issue.number,
150
+ issue_url: existing_issue.html_url,
151
+ issue_status: existing_issue.state,
152
+ issue_created_at: existing_issue.created_at
153
+ }
154
+ end
143
155
 
144
- title = @title_template.render(template_variables(comment, comment_sheet))
145
- body = @body_template.render(template_variables(comment, comment_sheet))
156
+ title = @title_template.render(template_variables(comment, comment_sheet))
157
+ body = body_with_marker(comment, comment_sheet)
146
158
 
147
- issue_options = {
148
- labels: determine_labels(comment, comment_sheet),
149
- assignees: determine_assignees(comment, comment_sheet, options),
150
- milestone: determine_milestone(comment, comment_sheet, options)
151
- }.compact
159
+ issue_options = {
160
+ labels: determine_labels(comment, comment_sheet),
161
+ assignees: determine_assignees(comment, comment_sheet, options),
162
+ milestone: determine_milestone(comment, comment_sheet, options)
163
+ }.compact
152
164
 
153
- puts "[GitHubIssueCreator] Creating issue with title: #{title}"
154
- begin
155
- issue = @github_client.create_issue(@repo, title, body, issue_options)
165
+ puts "[GitHubIssueCreator] Creating issue with title: #{title}"
166
+ issue = create_issue_with_retry(title, body, issue_options)
156
167
  puts "[GitHubIssueCreator] Issue created successfully: #{issue.html_url}"
157
168
  {
158
169
  comment_id: comment.id,
@@ -170,6 +181,66 @@ module Commenter
170
181
  end
171
182
  end
172
183
 
184
+ # Deterministic marker embedded in every created issue body, independent
185
+ # of the user's templates, so duplicate detection never has to rely on
186
+ # title matching alone (#1).
187
+ def issue_marker(comment, comment_sheet)
188
+ "#{MARKER_PREFIX}:#{@repo.downcase}:#{comment_sheet.stage.to_s.downcase}:#{comment.id}"
189
+ end
190
+
191
+ def body_with_marker(comment, comment_sheet)
192
+ rendered = @body_template.render(template_variables(comment, comment_sheet))
193
+ "#{rendered}\n\n---\n`#{issue_marker(comment, comment_sheet)}`"
194
+ end
195
+
196
+ def recorded_issue(comment, comment_sheet)
197
+ return nil unless comment.has_github_issue?
198
+
199
+ number = comment.github_issue_number
200
+ puts "[GitHubIssueCreator] Verifying recorded issue ##{number} for comment ID: #{comment.id}"
201
+ issue = @github_client.issue(@repo, number)
202
+ if issue.title.include?(render_unique_id(comment, comment_sheet)) ||
203
+ issue.body.to_s.include?(issue_marker(comment, comment_sheet))
204
+ issue
205
+ else
206
+ puts "[GitHubIssueCreator] Recorded issue ##{number} does not match, falling back to title search."
207
+ nil
208
+ end
209
+ rescue Octokit::NotFound
210
+ puts "[GitHubIssueCreator] Recorded issue ##{number} no longer exists, falling back to title search."
211
+ nil
212
+ end
213
+
214
+ def create_issue_with_retry(title, body, issue_options)
215
+ attempts = 0
216
+ begin
217
+ attempts += 1
218
+ issue = @github_client.create_issue(@repo, title, body, issue_options)
219
+ sleep(THROTTLE_SECONDS) if THROTTLE_SECONDS.positive?
220
+ issue
221
+ rescue Octokit::TooManyRequests, Octokit::Forbidden => e
222
+ delay = rate_limit_retry_delay(e)
223
+ if delay && attempts < RATE_LIMIT_MAX_RETRIES
224
+ puts "[GitHubIssueCreator] Rate limited (attempt #{attempts}/#{RATE_LIMIT_MAX_RETRIES}), retrying in #{delay}s: #{e.message}"
225
+ sleep(delay)
226
+ retry
227
+ end
228
+ raise
229
+ end
230
+ end
231
+
232
+ def rate_limit_retry_delay(error)
233
+ headers = error.response_headers
234
+ retry_after = headers && (headers[:retry_after] || headers["retry-after"])
235
+ return retry_after.to_i if retry_after.to_i.positive?
236
+
237
+ return RATE_LIMIT_RETRY_DELAY if error.is_a?(Octokit::TooManyRequests)
238
+
239
+ # Secondary ("abuse") rate limits are a 403 whose body names them; a
240
+ # permissions 403 must not be retried.
241
+ RATE_LIMIT_RETRY_DELAY if error.message.to_s.include?("secondary rate limit")
242
+ end
243
+
173
244
  def preview_issue(comment, comment_sheet)
174
245
  puts "[GitHubIssueCreator] Previewing issue for comment ID: #{comment.id}"
175
246
 
@@ -190,13 +261,15 @@ module Commenter
190
261
  unique_id = render_unique_id(comment, comment_sheet)
191
262
  puts "[GitHubIssueCreator] Searching for existing issue with unique_id: #{unique_id}"
192
263
 
193
- # Search for existing issues with the unique_id in the title
194
- # The unique_id is configurable and defaults to "[STAGE] COMMENT_ID"
264
+ # The marker search is exact and template-independent (#1); the title
265
+ # search remains as a fallback for issues created before the marker.
266
+ marker = issue_marker(comment, comment_sheet)
267
+ marker_results = @github_client.search_issues("repo:#{@repo} in:body \"#{marker}\"")
268
+ return marker_results.items.first if marker_results.items.any?
269
+
195
270
  query = "repo:#{@repo} in:title \"#{unique_id}\""
196
271
  results = @github_client.search_issues(query)
197
272
  results.items.first
198
- rescue Octokit::Error
199
- nil
200
273
  end
201
274
 
202
275
  def determine_labels(comment, comment_sheet)
@@ -211,8 +284,9 @@ module Commenter
211
284
  labels.concat(stage_labels) if stage_labels
212
285
  end
213
286
 
214
- # Add comment type label
215
- labels << comment.type if comment.type
287
+ # Comment type label: only for a recognized single type — combined or
288
+ # free-form values ("ge/te") must not mint labels (#3).
289
+ labels << comment.type if CommentType.known?(comment.type)
216
290
 
217
291
  labels.uniq
218
292
  end
@@ -285,9 +359,12 @@ module Commenter
285
359
  comment = comment_sheet.comments.find { |c| c.id == result[:comment_id] }
286
360
  next unless comment
287
361
 
362
+ created_at = comment.github_created_at || result[:issue_created_at] || Time.now.utc.iso8601
363
+ created_at = created_at.iso8601 if created_at.is_a?(Time)
364
+
288
365
  comment.record_github_issue(issue_number: result[:issue_number], issue_url: result[:issue_url],
289
- status: result[:status] == :created ? "open" : comment.github_status,
290
- created_at: comment.github_created_at || Time.now.utc.iso8601)
366
+ status: result[:issue_status] || (result[:status] == :created ? "open" : comment.github_status),
367
+ created_at: created_at)
291
368
  end
292
369
 
293
370
  # Write updated YAML
@@ -295,155 +372,4 @@ module Commenter
295
372
  File.write(output_file, comment_sheet.to_yaml_document)
296
373
  end
297
374
  end
298
-
299
- class GitHubIssueRetriever
300
- def initialize(config_path)
301
- @session = GitHubSession.new(config_path)
302
- @config = @session.config
303
- @github_client = @session.client
304
- @repo = @session.repo
305
- end
306
-
307
- def retrieve_observations_from_yaml(yaml_file, options = {})
308
- comment_sheet = CommentSheet.from_yaml(File.read(yaml_file))
309
-
310
- results = []
311
- comment_sheet.comments.each do |comment|
312
- next unless comment.has_github_issue?
313
-
314
- result = if options[:dry_run]
315
- preview_observation_retrieval(comment, options)
316
- else
317
- retrieve_observation(comment, options)
318
- end
319
- results << result
320
- end
321
-
322
- # Update YAML with observations (unless dry run)
323
- update_yaml_with_observations(yaml_file, comment_sheet, options) unless options[:dry_run]
324
-
325
- results
326
- end
327
-
328
- private
329
-
330
- def retrieve_observation(comment, options)
331
- issue_number = comment.github_issue_number
332
-
333
- begin
334
- issue = @github_client.issue(@repo, issue_number)
335
-
336
- # Skip open issues unless explicitly included
337
- if issue.state == "open" && !options[:include_open]
338
- return {
339
- comment_id: comment.id,
340
- issue_number: issue_number,
341
- status: :skipped,
342
- message: "Issue is still open"
343
- }
344
- end
345
-
346
- # Extract observation from issue comments
347
- observation = extract_observation_from_issue(issue_number)
348
-
349
- if observation
350
- # Update comment with observation and current status
351
- comment.observations = observation
352
- comment.github.status = issue.state
353
- comment.github.updated_at = Time.now.utc.iso8601
354
-
355
- {
356
- comment_id: comment.id,
357
- issue_number: issue_number,
358
- status: :retrieved,
359
- observation: observation
360
- }
361
- else
362
- {
363
- comment_id: comment.id,
364
- issue_number: issue_number,
365
- status: :skipped,
366
- message: "No observation found in issue"
367
- }
368
- end
369
- rescue Octokit::Error => e
370
- {
371
- comment_id: comment.id,
372
- issue_number: issue_number,
373
- status: :error,
374
- message: e.message
375
- }
376
- end
377
- end
378
-
379
- def preview_observation_retrieval(comment, _options)
380
- issue_number = comment.github_issue_number
381
-
382
- begin
383
- issue = @github_client.issue(@repo, issue_number)
384
- observation = extract_observation_from_issue(issue_number)
385
-
386
- {
387
- comment_id: comment.id,
388
- issue_number: issue_number,
389
- status: issue.state,
390
- observation: observation
391
- }
392
- rescue Octokit::Error => e
393
- {
394
- comment_id: comment.id,
395
- issue_number: issue_number,
396
- status: :error,
397
- message: e.message
398
- }
399
- end
400
- end
401
-
402
- def extract_observation_from_issue(issue_number)
403
- comments = @github_client.issue_comments(@repo, issue_number)
404
-
405
- # Look for magic comments with observation markers
406
- observation_markers = @config.dig("github", "retrieval", "observation_markers") ||
407
- ["**OBSERVATION:**", "**COMMENTER OBSERVATION:**"]
408
-
409
- # Search comments in reverse order (newest first)
410
- comments.reverse_each do |comment|
411
- observation = parse_observation_from_comment(comment.body, observation_markers)
412
- return observation if observation
413
- end
414
-
415
- # Fallback to last comment if configured and no magic comment found
416
- return comments.last.body.strip if @config.dig("github", "retrieval",
417
- "fallback_to_last_comment") && !comments.empty?
418
-
419
- nil
420
- rescue Octokit::Error
421
- nil
422
- end
423
-
424
- def parse_observation_from_comment(comment_body, markers)
425
- markers.each do |marker|
426
- # Look for markdown blockquote with the marker
427
- pattern = /^>\s*#{Regexp.escape(marker)}\s*\n((?:^>.*\n?)*)/m
428
- match = comment_body.match(pattern)
429
-
430
- next unless match
431
-
432
- # Extract the blockquote content and clean it up
433
- observation = match[1]
434
- .split("\n")
435
- .map { |line| line.sub(/^>\s?/, "") }
436
- .join("\n")
437
- .strip
438
- return observation unless observation.empty?
439
- end
440
-
441
- nil
442
- end
443
-
444
- def update_yaml_with_observations(yaml_file, comment_sheet, options)
445
- output_file = options[:output] || yaml_file
446
- File.write(output_file, comment_sheet.to_yaml_document)
447
- end
448
- end
449
375
  end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "octokit"
4
+ require "yaml"
5
+ require "dotenv/load"
6
+
7
+ module Commenter
8
+ # Pulls official observations (OBSERVATION: blockquotes) out of the GitHub
9
+ # issues recorded in the comments YAML and writes them back into the file.
10
+ class GitHubIssueRetriever
11
+ def initialize(config_path, client: nil)
12
+ @session = GitHubSession.new(config_path)
13
+ @config = @session.config
14
+ @github_client = client || @session.client
15
+ @repo = @session.repo
16
+ end
17
+
18
+ def retrieve_observations_from_yaml(yaml_file, options = {})
19
+ comment_sheet = CommentSheet.from_yaml(File.read(yaml_file))
20
+
21
+ results = []
22
+ comment_sheet.comments.each do |comment|
23
+ next unless comment.has_github_issue?
24
+
25
+ result = if options[:dry_run]
26
+ preview_observation_retrieval(comment, options)
27
+ else
28
+ retrieve_observation(comment, options)
29
+ end
30
+ results << result
31
+ end
32
+
33
+ # Update YAML with observations (unless dry run)
34
+ update_yaml_with_observations(yaml_file, comment_sheet, options) unless options[:dry_run]
35
+
36
+ results
37
+ end
38
+
39
+ private
40
+
41
+ def retrieve_observation(comment, _options)
42
+ issue_number = comment.github_issue_number
43
+
44
+ begin
45
+ issue = @github_client.issue(@repo, issue_number)
46
+
47
+ # An observation is filled in whenever it is present, open issue or
48
+ # not (#4); only the "still waiting" case is skipped.
49
+ observation = extract_observation_from_issue(issue_number)
50
+
51
+ if observation
52
+ comment.observations = observation
53
+ comment.github.status = issue.state
54
+ comment.github.updated_at = Time.now.utc.iso8601
55
+
56
+ {
57
+ comment_id: comment.id,
58
+ issue_number: issue_number,
59
+ status: :retrieved,
60
+ observation: observation
61
+ }
62
+ else
63
+ {
64
+ comment_id: comment.id,
65
+ issue_number: issue_number,
66
+ status: :skipped,
67
+ message: issue.state == "open" ? "Issue is still open (no observation yet)" : "No observation found in issue"
68
+ }
69
+ end
70
+ rescue Octokit::Error => e
71
+ {
72
+ comment_id: comment.id,
73
+ issue_number: issue_number,
74
+ status: :error,
75
+ message: e.message
76
+ }
77
+ end
78
+ end
79
+
80
+ def preview_observation_retrieval(comment, _options)
81
+ issue_number = comment.github_issue_number
82
+
83
+ begin
84
+ issue = @github_client.issue(@repo, issue_number)
85
+ observation = extract_observation_from_issue(issue_number)
86
+
87
+ {
88
+ comment_id: comment.id,
89
+ issue_number: issue_number,
90
+ status: issue.state,
91
+ observation: observation
92
+ }
93
+ rescue Octokit::Error => e
94
+ {
95
+ comment_id: comment.id,
96
+ issue_number: issue_number,
97
+ status: :error,
98
+ message: e.message
99
+ }
100
+ end
101
+ end
102
+
103
+ def extract_observation_from_issue(issue_number)
104
+ comments = @github_client.issue_comments(@repo, issue_number)
105
+
106
+ # Look for magic comments with observation markers
107
+ observation_markers = @config.dig("github", "retrieval", "observation_markers") ||
108
+ ["**OBSERVATION:**", "**COMMENTER OBSERVATION:**"]
109
+
110
+ # Search comments in reverse order (newest first)
111
+ comments.reverse_each do |comment|
112
+ observation = parse_observation_from_comment(comment.body, observation_markers)
113
+ return observation if observation
114
+ end
115
+
116
+ # Fallback to last comment if configured and no magic comment found
117
+ return comments.last.body.strip if @config.dig("github", "retrieval",
118
+ "fallback_to_last_comment") && !comments.empty?
119
+
120
+ nil
121
+ rescue Octokit::Error
122
+ nil
123
+ end
124
+
125
+ def parse_observation_from_comment(comment_body, markers)
126
+ markers.each do |marker|
127
+ # Look for markdown blockquote with the marker
128
+ pattern = /^>\s*#{Regexp.escape(marker)}\s*\n((?:^>.*\n?)*)/m
129
+ match = comment_body.match(pattern)
130
+
131
+ next unless match
132
+
133
+ # Extract the blockquote content and clean it up
134
+ observation = match[1]
135
+ .split("\n")
136
+ .map { |line| line.sub(/^>\s?/, "") }
137
+ .join("\n")
138
+ .strip
139
+ return observation unless observation.empty?
140
+ end
141
+
142
+ nil
143
+ end
144
+
145
+ def update_yaml_with_observations(yaml_file, comment_sheet, options)
146
+ output_file = options[:output] || yaml_file
147
+ File.write(output_file, comment_sheet.to_yaml_document)
148
+ end
149
+ end
150
+ end
@@ -61,7 +61,7 @@ module Commenter
61
61
  end
62
62
 
63
63
  def reconcile(comment, comment_sheet, options)
64
- issue = find_existing_issue(comment, comment_sheet)
64
+ issue = recorded_issue(comment, comment_sheet) || find_existing_issue(comment, comment_sheet)
65
65
  return create_issue(comment, comment_sheet, options) unless issue
66
66
 
67
67
  title = @title_template.render(template_variables(comment, comment_sheet))
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commenter
4
- VERSION = "0.6.0"
4
+ VERSION = "0.6.1"
5
5
  end
data/lib/commenter.rb CHANGED
@@ -20,6 +20,6 @@ module Commenter
20
20
  autoload :Parser, "commenter/parser"
21
21
  autoload :Filler, "commenter/filler"
22
22
  autoload :GitHubIssueCreator, "commenter/github_integration"
23
- autoload :GitHubIssueRetriever, "commenter/github_integration"
23
+ autoload :GitHubIssueRetriever, "commenter/github_issue_retriever"
24
24
  autoload :GitHubSync, "commenter/github_sync"
25
25
  end
@@ -43,3 +43,19 @@ RSpec.describe Commenter::CommentType do
43
43
  end
44
44
  end
45
45
  end
46
+
47
+ RSpec.describe Commenter::CommentType do
48
+ describe ".known?" do
49
+ it "accepts recognized codes and full names" do
50
+ expect(described_class.known?("te")).to be(true)
51
+ expect(described_class.known?("Technical")).to be(true)
52
+ expect(described_class.known?("editorial")).to be(true)
53
+ end
54
+
55
+ it "rejects combined or free-form values" do
56
+ expect(described_class.known?("ge/te")).to be(false)
57
+ expect(described_class.known?("")).to be(false)
58
+ expect(described_class.known?(nil)).to be(false)
59
+ end
60
+ end
61
+ end
@@ -4,6 +4,76 @@ require "spec_helper"
4
4
  require "tempfile"
5
5
  require "yaml"
6
6
 
7
+ FakeIssue = Struct.new(:number, :title, :body, :html_url, :state, :created_at, keyword_init: true)
8
+ FakeSearchResults = Struct.new(:items, keyword_init: true)
9
+ FakeMilestone = Struct.new(:number, :title, keyword_init: true)
10
+ MarkerIssue = Struct.new(:number, :html_url, :title, :body, :state, :created_at, keyword_init: true)
11
+ MarkerResults = Struct.new(:items, keyword_init: true)
12
+ RetrieverIssue = Struct.new(:number, :state, keyword_init: true)
13
+ RetrieverComment = Struct.new(:body, keyword_init: true)
14
+
15
+ # Real stand-in for the Octokit surface github-create uses: a seeded issue
16
+ # store plus counters, so duplicate-prevention and retry behavior is
17
+ # exercised against an object with the same protocol as Octokit::Client.
18
+ class FakeGitHubClient
19
+ attr_accessor :rate_limit_failures, :search_error
20
+ attr_reader :created
21
+
22
+ def initialize(existing_issues = [])
23
+ @issues = {}
24
+ existing_issues.each { |issue| @issues[issue.number] = issue }
25
+ @next_number = @issues.keys.max.to_i
26
+ @created = []
27
+ @rate_limit_failures = 0
28
+ end
29
+
30
+ def issue(_repo, number)
31
+ @issues.fetch(number) { raise Octokit::NotFound }
32
+ end
33
+
34
+ def search_issues(query)
35
+ raise search_error if search_error
36
+
37
+ if (marker = query[/in:body "(.+)"\z/, 1])
38
+ FakeSearchResults.new(items: @issues.values.select { |i| i.body.to_s.include?(marker) })
39
+ else
40
+ unique_id = query[/in:title "(.+)"\z/, 1]
41
+ FakeSearchResults.new(items: @issues.values.select { |i| i.title.include?(unique_id) })
42
+ end
43
+ end
44
+
45
+ def milestones(_repo, _options = {})
46
+ [FakeMilestone.new(number: 1, title: "Test Milestone")]
47
+ end
48
+
49
+ def create_issue(_repo, title, body, _options = {})
50
+ if @rate_limit_failures.positive?
51
+ @rate_limit_failures -= 1
52
+ rate_limited = Octokit::TooManyRequests.new(rate_limited_response)
53
+ raise rate_limited
54
+ end
55
+
56
+ @next_number += 1
57
+ issue = FakeIssue.new(number: @next_number, title: title, body: body,
58
+ html_url: "https://example.test/issues/#{@next_number}",
59
+ state: "open", created_at: "2026-09-07T00:00:00Z")
60
+ @issues[issue.number] = issue
61
+ @created << issue
62
+ issue
63
+ end
64
+
65
+ private
66
+
67
+ # Mirrors the response hash Octokit builds from a real 403.
68
+ def rate_limited_response
69
+ {
70
+ status: 403,
71
+ body: "You have exceeded a secondary rate limit and have been " \
72
+ "temporarily blocked from content creation."
73
+ }
74
+ end
75
+ end
76
+
7
77
  RSpec.describe Commenter::GitHubIssueCreator do
8
78
  let(:config_data) do
9
79
  {
@@ -131,6 +201,148 @@ RSpec.describe Commenter::GitHubIssueCreator do
131
201
  result = results.first
132
202
  expect(result[:labels]).not_to include("draft-international-standard")
133
203
  end
204
+
205
+ context "with an injected client, without dry run" do
206
+ before do
207
+ stub_const("Commenter::GitHubIssueCreator::THROTTLE_SECONDS", 0)
208
+ stub_const("Commenter::GitHubIssueCreator::RATE_LIMIT_RETRY_DELAY", 0)
209
+ end
210
+
211
+ def write_sheet(comments)
212
+ data = yaml_data
213
+ data["comments"] = comments
214
+ file = Tempfile.new(["comments", ".yaml"])
215
+ file.write(data.to_yaml)
216
+ file.close
217
+ file
218
+ end
219
+
220
+ def seeded_issue(number, title)
221
+ FakeIssue.new(number: number, title: title,
222
+ html_url: "https://example.test/issues/#{number}",
223
+ state: "open", created_at: "2026-09-06T00:00:00Z")
224
+ end
225
+
226
+ it "skips creation when the YAML records a matching issue" do
227
+ client = FakeGitHubClient.new([seeded_issue(7, "[DIS] US-001: already posted")])
228
+ sheet = write_sheet([yaml_data["comments"].first.merge("github" => { "issue_number" => 7 })])
229
+ creator = described_class.new(config_file.path, title_template_file.path,
230
+ body_template_file.path, client: client)
231
+
232
+ results = creator.create_issues_from_yaml(sheet.path)
233
+
234
+ expect(results.first[:status]).to eq(:skipped)
235
+ expect(results.first[:issue_number]).to eq(7)
236
+ expect(results.first[:issue_url]).to eq("https://example.test/issues/7")
237
+ expect(client.created).to be_empty
238
+ expect(Commenter::CommentSheet.from_yaml(File.read(sheet.path)).comments.first.github_issue_number).to eq(7)
239
+ sheet.unlink
240
+ end
241
+
242
+ it "falls back to search when the recorded issue no longer exists" do
243
+ client = FakeGitHubClient.new([seeded_issue(5, "[DIS] US-001: posted earlier")])
244
+ sheet = write_sheet([yaml_data["comments"].first.merge("github" => { "issue_number" => 99 })])
245
+ creator = described_class.new(config_file.path, title_template_file.path,
246
+ body_template_file.path, client: client)
247
+
248
+ results = creator.create_issues_from_yaml(sheet.path)
249
+
250
+ expect(results.first[:status]).to eq(:skipped)
251
+ expect(results.first[:issue_number]).to eq(5)
252
+ expect(client.created).to be_empty
253
+ expect(Commenter::CommentSheet.from_yaml(File.read(sheet.path)).comments.first.github_issue_number).to eq(5)
254
+ sheet.unlink
255
+ end
256
+
257
+ it "falls back to search when the recorded issue title does not match the stage" do
258
+ client = FakeGitHubClient.new([seeded_issue(7, "[WD] US-001: different stage"),
259
+ seeded_issue(5, "[DIS] US-001: posted earlier")])
260
+ sheet = write_sheet([yaml_data["comments"].first.merge("github" => { "issue_number" => 7 })])
261
+ creator = described_class.new(config_file.path, title_template_file.path,
262
+ body_template_file.path, client: client)
263
+
264
+ results = creator.create_issues_from_yaml(sheet.path)
265
+
266
+ expect(results.first[:status]).to eq(:skipped)
267
+ expect(results.first[:issue_number]).to eq(5)
268
+ expect(client.created).to be_empty
269
+ sheet.unlink
270
+ end
271
+
272
+ it "records the issue in the YAML when skipping via title search" do
273
+ client = FakeGitHubClient.new([seeded_issue(5, "[DIS] US-001: posted earlier")])
274
+ sheet = write_sheet(yaml_data["comments"])
275
+ creator = described_class.new(config_file.path, title_template_file.path,
276
+ body_template_file.path, client: client)
277
+
278
+ results = creator.create_issues_from_yaml(sheet.path)
279
+
280
+ expect(results.first[:status]).to eq(:skipped)
281
+ expect(Commenter::CommentSheet.from_yaml(File.read(sheet.path)).comments.first.github_issue_number).to eq(5)
282
+ sheet.unlink
283
+ end
284
+
285
+ it "creates and records the issue when nothing exists" do
286
+ client = FakeGitHubClient.new
287
+ sheet = write_sheet(yaml_data["comments"])
288
+ creator = described_class.new(config_file.path, title_template_file.path,
289
+ body_template_file.path, client: client)
290
+
291
+ results = creator.create_issues_from_yaml(sheet.path)
292
+
293
+ expect(results.first[:status]).to eq(:created)
294
+ expect(results.first[:issue_number]).to eq(1)
295
+ expect(client.created.length).to eq(1)
296
+ expect(Commenter::CommentSheet.from_yaml(File.read(sheet.path)).comments.first.github_issue_number).to eq(1)
297
+ sheet.unlink
298
+ end
299
+
300
+ it "retries creation after a rate limit" do
301
+ client = FakeGitHubClient.new
302
+ client.rate_limit_failures = 1
303
+ sheet = write_sheet(yaml_data["comments"])
304
+ creator = described_class.new(config_file.path, title_template_file.path,
305
+ body_template_file.path, client: client)
306
+
307
+ results = creator.create_issues_from_yaml(sheet.path)
308
+
309
+ expect(results.first[:status]).to eq(:created)
310
+ expect(client.created.length).to eq(1)
311
+ sheet.unlink
312
+ end
313
+
314
+ it "reports an error when rate limiting persists" do
315
+ client = FakeGitHubClient.new
316
+ client.rate_limit_failures = 99
317
+ sheet = write_sheet(yaml_data["comments"])
318
+ creator = described_class.new(config_file.path, title_template_file.path,
319
+ body_template_file.path, client: client)
320
+
321
+ results = creator.create_issues_from_yaml(sheet.path)
322
+
323
+ expect(results.first[:status]).to eq(:error)
324
+ expect(client.created).to be_empty
325
+ sheet.unlink
326
+ end
327
+
328
+ it "reports an error instead of creating when the title search fails" do
329
+ client = FakeGitHubClient.new
330
+ client.search_error = Octokit::Forbidden.new(
331
+ status: 403,
332
+ body: "You have exceeded a secondary rate limit and have been " \
333
+ "temporarily blocked from content creation."
334
+ )
335
+ sheet = write_sheet(yaml_data["comments"])
336
+ creator = described_class.new(config_file.path, title_template_file.path,
337
+ body_template_file.path, client: client)
338
+
339
+ results = creator.create_issues_from_yaml(sheet.path)
340
+
341
+ expect(results.first[:status]).to eq(:error)
342
+ expect(client.created).to be_empty
343
+ sheet.unlink
344
+ end
345
+ end
134
346
  end
135
347
 
136
348
  describe "template variable generation" do
@@ -214,3 +426,145 @@ RSpec.describe Commenter::GitHubIssueRetriever do
214
426
  end
215
427
  end
216
428
  end
429
+
430
+ RSpec.describe Commenter::GitHubIssueCreator do
431
+ let(:config_file) do
432
+ file = Tempfile.new(["config", ".yaml"])
433
+ file.write({ "github" => { "repository" => "test-org/test-repo", "token" => "t",
434
+ "default_labels" => ["comment-review"] } }.to_yaml)
435
+ file.close
436
+ file
437
+ end
438
+ let(:client) { Octokit::Client.new(access_token: "dummy") }
439
+ let(:creator) { described_class.new(config_file.path, client: client) }
440
+
441
+ def sheet_with(type)
442
+ Commenter::CommentSheet.new(stage: "DIS", comments: [
443
+ Commenter::Comment.new(id: "US-001", body: "US", type: type, comments: "Text")
444
+ ])
445
+ end
446
+
447
+ after { config_file.unlink }
448
+
449
+ describe "comment type labels (#3)" do
450
+ it "labels recognized types and never mints labels from combined values" do
451
+ recognized = creator.create_issues_from_yaml(write_yaml(sheet_with("technical")), dry_run: true)
452
+ combined = creator.create_issues_from_yaml(write_yaml(sheet_with("ge/te")), dry_run: true)
453
+
454
+ expect(recognized.first[:labels]).to include("technical")
455
+ expect(combined.first[:labels]).to eq(["comment-review"])
456
+ end
457
+
458
+ def write_yaml(sheet)
459
+ file = Tempfile.new(["comments", ".yaml"])
460
+ file.write(sheet.to_yaml_document)
461
+ file.close
462
+ file
463
+ end
464
+ end
465
+
466
+ describe "issue marker (#1)" do
467
+ it "embeds the marker in created issue bodies" do
468
+ empty = MarkerResults.new(items: [])
469
+ allow(client).to receive(:search_issues).and_return(empty)
470
+ created = MarkerIssue.new(number: 9, html_url: "https://github.com/test-org/test-repo/issues/9",
471
+ title: "T", body: "B", state: "open")
472
+ allow(client).to receive(:create_issue) { |_repo, _title, _body, _opts| created }
473
+
474
+ file = write_yaml_with_github(nil)
475
+ creator.create_issues_from_yaml(file)
476
+
477
+ expect(client).to have_received(:create_issue)
478
+ .with(anything, anything, %r{urn:commenter:test-org/test-repo:dis:US-001}, anything)
479
+ file.unlink
480
+ end
481
+
482
+ it "finds existing issues through the marker before the title search" do
483
+ marker_hit = MarkerIssue.new(number: 9, html_url: "u", title: "t", body: "b", state: "open", created_at: nil)
484
+ allow(client).to receive(:search_issues).with(/in:body/).and_return(MarkerResults.new(items: [marker_hit]))
485
+ allow(client).to receive(:search_issues).with(/in:title/).and_raise("title search must not run")
486
+
487
+ file = write_yaml_with_github(nil)
488
+ results = creator.create_issues_from_yaml(file)
489
+
490
+ expect(results.first[:status]).to eq(:skipped)
491
+ expect(results.first[:issue_number]).to eq(9)
492
+ file.unlink
493
+ end
494
+
495
+ it "verifies recorded issues by marker when the title does not carry the unique id" do
496
+ recorded = MarkerIssue.new(number: 9, html_url: "u", title: "Custom title", created_at: nil,
497
+ body: "discussed in `urn:commenter:test-org/test-repo:dis:US-001`", state: "open")
498
+ allow(client).to receive(:issue).with("test-org/test-repo", 9).and_return(recorded)
499
+ allow(client).to receive(:search_issues).and_raise("search must not run")
500
+
501
+ file = write_yaml_with_github(9)
502
+ results = creator.create_issues_from_yaml(file)
503
+
504
+ expect(results.first[:status]).to eq(:skipped)
505
+ file.unlink
506
+ end
507
+
508
+ def write_yaml_with_github(issue_number)
509
+ github = issue_number ? { "issue_number" => issue_number, "status" => "open" } : nil
510
+ file = Tempfile.new(["comments", ".yaml"])
511
+ file.write({ "version" => "2012-03", "stage" => "DIS",
512
+ "comments" => [{ "id" => "US-001", "body" => "US", "comments" => "Text",
513
+ "github" => github }.compact] }.to_yaml)
514
+ file.close
515
+ file
516
+ end
517
+ end
518
+ end
519
+
520
+ RSpec.describe Commenter::GitHubIssueRetriever do
521
+ let(:config_file) do
522
+ file = Tempfile.new(["config", ".yaml"])
523
+ file.write({ "github" => { "repository" => "test-org/test-repo", "token" => "t" } }.to_yaml)
524
+ file.close
525
+ file
526
+ end
527
+ let(:client) { Octokit::Client.new(access_token: "dummy") }
528
+ let(:retriever) { described_class.new(config_file.path, client: client) }
529
+
530
+ after { config_file.unlink }
531
+
532
+ def retrieve_with(issue_state, comments)
533
+ Dir.mktmpdir do |dir|
534
+ input = File.join(dir, "comments.yaml")
535
+ File.write(input, { "version" => "2012-03",
536
+ "comments" => [{ "id" => "US-001", "comments" => "Text",
537
+ "github" => { "issue_number" => 5, "status" => "open" } }] }.to_yaml)
538
+ allow(client).to receive(:issue).with("test-org/test-repo", 5)
539
+ .and_return(RetrieverIssue.new(number: 5, state: issue_state))
540
+ allow(client).to receive(:issue_comments).and_return(comments)
541
+
542
+ result = retriever.retrieve_observations_from_yaml(input)[0]
543
+ sheet = Commenter::CommentSheet.from_yaml(File.read(input))
544
+ [result, sheet.comments.first]
545
+ end
546
+ end
547
+
548
+ it "fills an observation from an open issue (#4)" do
549
+ result, comment = retrieve_with("open", [RetrieverComment.new(body: "> **OBSERVATION:**\n> Accepted.")])
550
+
551
+ expect(result[:status]).to eq(:retrieved)
552
+ expect(comment.observations).to eq("Accepted.")
553
+ end
554
+
555
+ it "skips an open issue only when no observation exists yet" do
556
+ result, comment = retrieve_with("open", [RetrieverComment.new(body: "Still discussing")])
557
+
558
+ expect(result[:status]).to eq(:skipped)
559
+ expect(result[:message]).to include("still open")
560
+ expect(comment.observations).to be_nil
561
+ end
562
+
563
+ it "fills observations from closed issues and records the status" do
564
+ result, comment = retrieve_with("closed", [RetrieverComment.new(body: "> **OBSERVATION:**\n> Noted.")])
565
+
566
+ expect(result[:status]).to eq(:retrieved)
567
+ expect(comment.observations).to eq("Noted.")
568
+ expect(comment.github_status).to eq("closed")
569
+ end
570
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commenter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-29 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -163,6 +163,7 @@ files:
163
163
  - lib/commenter/filler.rb
164
164
  - lib/commenter/github_info.rb
165
165
  - lib/commenter/github_integration.rb
166
+ - lib/commenter/github_issue_retriever.rb
166
167
  - lib/commenter/github_session.rb
167
168
  - lib/commenter/github_sync.rb
168
169
  - lib/commenter/parser.rb