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 +4 -4
- data/.rubocop_todo.yml +1 -1
- data/CLAUDE.md +2 -2
- data/README.adoc +15 -3
- data/lib/commenter/cli.rb +44 -40
- data/lib/commenter/comment_type.rb +7 -0
- data/lib/commenter/github_integration.rb +108 -182
- data/lib/commenter/github_issue_retriever.rb +150 -0
- data/lib/commenter/github_sync.rb +1 -1
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +1 -1
- data/spec/commenter/comment_type_spec.rb +16 -0
- data/spec/commenter/github_integration_spec.rb +354 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 116bd5694762b20a730aa2d67d9950e3acedecf5e0e5d2f95a6d183d376c0e97
|
|
4
|
+
data.tar.gz: d1c34482394b44f5195a91268eb3b46f4db58c986afc214a841d74b908f98edb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d755c6bfefe119528e4ddb9e65033263f5c03550d972ae29bd62aaaea8df3fa571439305f80429eefb017856d387e5dde23c18a744ecdfb03d2f77d6a6e486e
|
|
7
|
+
data.tar.gz: 6d297683a681dc1918be0db3a5dd63aded21fca0fce0a63799b36e1342d60f927b9690ca9757c8935c74c530edc3205acb7b510e4930360a7f4a99d595a865fc
|
data/.rubocop_todo.yml
CHANGED
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`)
|
|
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
|
|
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
|
-
*
|
|
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` -
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
156
|
+
title = @title_template.render(template_variables(comment, comment_sheet))
|
|
157
|
+
body = body_with_marker(comment, comment_sheet)
|
|
146
158
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
#
|
|
194
|
-
#
|
|
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
|
-
#
|
|
215
|
-
|
|
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:
|
|
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))
|
data/lib/commenter/version.rb
CHANGED
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/
|
|
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.
|
|
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
|
|
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
|