commenter 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +1 -1
- data/CLAUDE.md +2 -2
- data/README.adoc +27 -3
- data/lib/commenter/cli.rb +62 -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/sheet_validator.rb +55 -0
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +2 -1
- data/spec/commenter/comment_type_spec.rb +16 -0
- data/spec/commenter/github_integration_spec.rb +354 -0
- data/spec/commenter/sheet_validator_spec.rb +44 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94c3f6c75b5bf2bc60da48e3b38f3bfcbc17eb259d29834bc9b4a496da1a70fb
|
|
4
|
+
data.tar.gz: 5533dfb8fab486205a4f6a164ee5604561354d116e36018fc487913d036cf56a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66614ec628ece4764cb10b33fc6213de4dad08fc72dba5a89a2c0cebda64848795f7a7372b0ed8b44b081ec4a8daa4b80f5605867986672a32ac7c26f2778545
|
|
7
|
+
data.tar.gz: 866aa00a35e504930f882ca391d335d7bb7602e77da086148712797e9772da0683ae5055350aaf2515a4ea8b620793c3780e2c0dae8fe63486e55410a8c19e07
|
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`, `validate`, `fill`, `export`, `github-create`, `github-sync`, `github-retrieve`.
|
|
54
54
|
|
|
55
55
|
### Schemas
|
|
56
56
|
|
data/README.adoc
CHANGED
|
@@ -234,6 +234,18 @@ issue counts as *open*; no disposition at all counts as *undecided*). Use
|
|
|
234
234
|
`--format yaml` for machine-readable output, and `-o report.md` to write to a
|
|
235
235
|
file — the Markdown pastes directly into a GitHub issue or WG minutes.
|
|
236
236
|
|
|
237
|
+
=== Validating a comment sheet
|
|
238
|
+
|
|
239
|
+
The shipped schemas power IDE validation; `commenter validate` checks the
|
|
240
|
+
same structure from the command line — unknown versions, missing ids or
|
|
241
|
+
comment text, duplicate ids, and unrecognized comment types — and exits
|
|
242
|
+
non-zero when errors are found, so it fits CI and pre-flight checks:
|
|
243
|
+
|
|
244
|
+
[source,shell]
|
|
245
|
+
----
|
|
246
|
+
commenter validate ballot.yaml
|
|
247
|
+
----
|
|
248
|
+
|
|
237
249
|
=== Comparing ballots between stages
|
|
238
250
|
|
|
239
251
|
The same comment ID (`DE-001`) recurs at every ballot stage; comparing two
|
|
@@ -294,7 +306,7 @@ Options:
|
|
|
294
306
|
The commenter gem provides comprehensive GitHub integration for collaborative comment review and tracking. The workflow consists of two main commands:
|
|
295
307
|
|
|
296
308
|
* `github-create` - Creates GitHub issues from comments and tracks them in YAML
|
|
297
|
-
* `github-retrieve` - Retrieves
|
|
309
|
+
* `github-retrieve` - Retrieves observations recorded in GitHub issues
|
|
298
310
|
|
|
299
311
|
==== Creating GitHub issues from comments
|
|
300
312
|
|
|
@@ -431,6 +443,18 @@ detection uses a **configurable unique identifier** that is:
|
|
|
431
443
|
This ensures that the search pattern always matches what appears in the title,
|
|
432
444
|
preventing synchronization issues.
|
|
433
445
|
|
|
446
|
+
Every created issue body also carries a deterministic marker line, e.g.
|
|
447
|
+
+
|
|
448
|
+
[source]
|
|
449
|
+
----
|
|
450
|
+
`urn:commenter:owner/repo:dis:GB-001`
|
|
451
|
+
----
|
|
452
|
+
+
|
|
453
|
+
Duplicate detection searches the issue bodies for this marker first — exact
|
|
454
|
+
and template-independent — and falls back to the title search for issues
|
|
455
|
+
created before the marker existed. Comment numbers already recorded in the
|
|
456
|
+
comments YAML are verified with a direct read before any search runs.
|
|
457
|
+
|
|
434
458
|
===== Unique ID template
|
|
435
459
|
|
|
436
460
|
The unique identifier is defined by the `unique_id` template in your configuration:
|
|
@@ -534,7 +558,7 @@ $ commenter github-retrieve --config github_config.yaml comments.yaml
|
|
|
534
558
|
This command:
|
|
535
559
|
|
|
536
560
|
* Reads GitHub issue information from the YAML file (no searching required)
|
|
537
|
-
*
|
|
561
|
+
* Fills in an observation whenever the issue carries one, open or closed
|
|
538
562
|
* Updates the same YAML file with extracted observations
|
|
539
563
|
* Preserves all existing comment data
|
|
540
564
|
|
|
@@ -561,7 +585,7 @@ Alternative shorter syntax:
|
|
|
561
585
|
|
|
562
586
|
* `-c, --config FILE` - GitHub configuration YAML file (required)
|
|
563
587
|
* `-o, --output FILE` - Output YAML file (default: update original)
|
|
564
|
-
* `--include-open` -
|
|
588
|
+
* `--include-open` - (Deprecated) observations are now filled from open issues too
|
|
565
589
|
* `--dry-run` - Preview observations without updating YAML
|
|
566
590
|
|
|
567
591
|
===== Examples
|
data/lib/commenter/cli.rb
CHANGED
|
@@ -86,6 +86,24 @@ module Commenter
|
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
desc "validate INPUT.yaml", "Check a comment sheet for structural problems"
|
|
90
|
+
def validate(input_yaml)
|
|
91
|
+
comment_sheet = CommentSheet.from_yaml(File.read(input_yaml))
|
|
92
|
+
problems = SheetValidator.call(comment_sheet)
|
|
93
|
+
|
|
94
|
+
if problems.empty?
|
|
95
|
+
puts "OK: #{comment_sheet.comments.length} comments, no problems found"
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
problems.each do |problem|
|
|
100
|
+
where = problem[:comment] ? "#{problem[:comment]}: " : ""
|
|
101
|
+
puts "#{problem[:severity].to_s.upcase}: #{where}#{problem[:message]}"
|
|
102
|
+
end
|
|
103
|
+
errors = problems.count { |problem| problem[:severity] == :error }
|
|
104
|
+
raise Commenter::Error, "#{errors} error(s) found in #{input_yaml}"
|
|
105
|
+
end
|
|
106
|
+
|
|
89
107
|
desc "fill INPUT.yaml", "Fill DOCX template from YAML comments"
|
|
90
108
|
option :output, type: :string, aliases: :o, default: "filled_comments.docx", desc: "Output DOCX file"
|
|
91
109
|
option :template, type: :string, aliases: :t, desc: "Custom template file"
|
|
@@ -157,46 +175,8 @@ module Commenter
|
|
|
157
175
|
|
|
158
176
|
results = creator.create_issues_from_yaml(input_yaml, github_options)
|
|
159
177
|
|
|
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
|
|
178
|
+
error_count = report_issue_results(results, options[:dry_run])
|
|
179
|
+
exit 1 if error_count.to_i.positive?
|
|
200
180
|
rescue StandardError => e
|
|
201
181
|
puts "Error: #{e.message}"
|
|
202
182
|
exit 1
|
|
@@ -341,5 +321,47 @@ module Commenter
|
|
|
341
321
|
FileUtils.cp(schema_source, schema_target) unless File.expand_path(schema_source) == File.expand_path(schema_target)
|
|
342
322
|
schema_target
|
|
343
323
|
end
|
|
324
|
+
|
|
325
|
+
def report_issue_results(results, dry_run)
|
|
326
|
+
if dry_run
|
|
327
|
+
puts "DRY RUN - Preview of issues to be created:"
|
|
328
|
+
puts "=" * 50
|
|
329
|
+
results.each do |result|
|
|
330
|
+
puts "\nComment ID: #{result[:comment_id]}"
|
|
331
|
+
puts "Title: #{result[:title]}"
|
|
332
|
+
puts "Labels: #{result[:labels].join(", ")}" if result[:labels]&.any?
|
|
333
|
+
puts "Assignees: #{result[:assignees].join(", ")}" if result[:assignees]&.any?
|
|
334
|
+
puts "Milestone: #{result[:milestone]}" if result[:milestone]
|
|
335
|
+
puts "\nBody preview (first 200 chars):"
|
|
336
|
+
puts result[:body][0...200] + (result[:body].length > 200 ? "..." : "")
|
|
337
|
+
puts "-" * 30
|
|
338
|
+
end
|
|
339
|
+
return nil
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
puts "GitHub issue creation results:"
|
|
343
|
+
puts "=" * 40
|
|
344
|
+
|
|
345
|
+
counts = Hash.new(0)
|
|
346
|
+
results.each do |result|
|
|
347
|
+
case result[:status]
|
|
348
|
+
when :created
|
|
349
|
+
counts[:created] += 1
|
|
350
|
+
puts "✓ #{result[:comment_id]}: Created issue ##{result[:issue_number]}"
|
|
351
|
+
puts " URL: #{result[:issue_url]}"
|
|
352
|
+
when :skipped
|
|
353
|
+
counts[:skipped] += 1
|
|
354
|
+
puts "- #{result[:comment_id]}: Skipped (#{result[:message]})"
|
|
355
|
+
puts " URL: #{result[:issue_url]}" if result[:issue_url]
|
|
356
|
+
when :error
|
|
357
|
+
counts[:error] += 1
|
|
358
|
+
puts "✗ #{result[:comment_id]}: Error - #{result[:message]}"
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
puts "\nSummary:"
|
|
363
|
+
puts "Created: #{counts[:created]}, Skipped: #{counts[:skipped]}, Errors: #{counts[:error]}"
|
|
364
|
+
counts[:error]
|
|
365
|
+
end
|
|
344
366
|
end
|
|
345
367
|
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))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commenter
|
|
4
|
+
# Structural validation of a comment sheet — the checks the CLI schemas
|
|
5
|
+
# express, living next to the model so the vocabulary stays in one place.
|
|
6
|
+
# Returns a list of problems; entries are hashes with :severity (:error or
|
|
7
|
+
# :warning), :comment (id when the problem belongs to one comment), and
|
|
8
|
+
# :message. An empty list means the sheet is valid.
|
|
9
|
+
module SheetValidator
|
|
10
|
+
VERSIONS = %w[2012-03 osd].freeze
|
|
11
|
+
STAGES = %w[WD CD DIS FDIS PRF PUB].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def call(sheet)
|
|
16
|
+
problems = []
|
|
17
|
+
unless VERSIONS.include?(sheet.version)
|
|
18
|
+
problems << { severity: :error,
|
|
19
|
+
message: "unknown version #{sheet.version.inspect} (expected #{VERSIONS.join(" or ")})" }
|
|
20
|
+
end
|
|
21
|
+
if sheet.stage && !STAGES.include?(sheet.stage)
|
|
22
|
+
problems << { severity: :warning,
|
|
23
|
+
message: "unusual stage #{sheet.stage.inspect} (expected one of #{STAGES.join(", ")})" }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sheet.comments.each_with_index do |comment, index|
|
|
27
|
+
problems.concat(comment_problems(comment, index))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
duplicates = sheet.comments.group_by { |comment| comment.id.to_s }
|
|
31
|
+
.reject { |id, _| id.empty? }
|
|
32
|
+
.select { |_, comments| comments.length > 1 }
|
|
33
|
+
.keys
|
|
34
|
+
duplicates.sort.each do |id|
|
|
35
|
+
problems << { severity: :error, comment: id, message: "duplicate comment id #{id}" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
problems
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def comment_problems(comment, index)
|
|
42
|
+
problems = []
|
|
43
|
+
label = comment.id.to_s.empty? ? "comment ##{index + 1}" : comment.id
|
|
44
|
+
|
|
45
|
+
problems << { severity: :error, comment: label, message: "missing id" } if comment.id.to_s.strip.empty?
|
|
46
|
+
problems << { severity: :error, comment: label, message: "missing comment text" } if comment.comments.to_s.strip.empty?
|
|
47
|
+
unless comment.type.to_s.strip.empty? || CommentType.known?(comment.type)
|
|
48
|
+
problems << { severity: :warning, comment: label,
|
|
49
|
+
message: "unrecognized comment type #{comment.type.inspect} (no label will be minted for it)" }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
problems
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/commenter/version.rb
CHANGED
data/lib/commenter.rb
CHANGED
|
@@ -14,12 +14,13 @@ module Commenter
|
|
|
14
14
|
autoload :Ballot, "commenter/ballot"
|
|
15
15
|
autoload :BallotReport, "commenter/ballot_report"
|
|
16
16
|
autoload :BallotDiff, "commenter/ballot_diff"
|
|
17
|
+
autoload :SheetValidator, "commenter/sheet_validator"
|
|
17
18
|
autoload :Comment, "commenter/comment"
|
|
18
19
|
autoload :CommentSheet, "commenter/comment_sheet"
|
|
19
20
|
autoload :GitHubSession, "commenter/github_session"
|
|
20
21
|
autoload :Parser, "commenter/parser"
|
|
21
22
|
autoload :Filler, "commenter/filler"
|
|
22
23
|
autoload :GitHubIssueCreator, "commenter/github_integration"
|
|
23
|
-
autoload :GitHubIssueRetriever, "commenter/
|
|
24
|
+
autoload :GitHubIssueRetriever, "commenter/github_issue_retriever"
|
|
24
25
|
autoload :GitHubSync, "commenter/github_sync"
|
|
25
26
|
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
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Commenter::SheetValidator do
|
|
6
|
+
def sheet(comments, version: "2012-03", stage: nil)
|
|
7
|
+
Commenter::CommentSheet.new(version: version, stage: stage, comments: comments)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def comment(id, text: "Text", type: "technical")
|
|
11
|
+
Commenter::Comment.new(id: id, body: id.to_s.split("-").first, comments: text, type: type)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "accepts a clean sheet" do
|
|
15
|
+
expect(described_class.call(sheet([comment("DE-001")]))).to eq([])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "reports unknown versions and unusual stages" do
|
|
19
|
+
problems = described_class.call(sheet([comment("DE-001")], version: "2018-01", stage: "CUSTOM"))
|
|
20
|
+
|
|
21
|
+
expect(problems).to include(severity: :error, message: a_string_including("unknown version"))
|
|
22
|
+
expect(problems).to include(severity: :warning, message: a_string_including("unusual stage"))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "reports missing ids, missing text, and duplicate ids as errors" do
|
|
26
|
+
problems = described_class.call(sheet([
|
|
27
|
+
comment("", text: "No id"),
|
|
28
|
+
comment("DE-001"),
|
|
29
|
+
comment("DE-001"),
|
|
30
|
+
comment("DE-002", text: "")
|
|
31
|
+
]))
|
|
32
|
+
|
|
33
|
+
errors = problems.select { |problem| problem[:severity] == :error }
|
|
34
|
+
expect(errors.map { |problem| problem[:message] }).to include("missing id", "missing comment text",
|
|
35
|
+
"duplicate comment id DE-001")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "warns about unrecognized comment types without failing" do
|
|
39
|
+
problems = described_class.call(sheet([comment("DE-001", type: "ge/te")]))
|
|
40
|
+
|
|
41
|
+
expect(problems).to contain_exactly(severity: :warning, comment: "DE-001",
|
|
42
|
+
message: a_string_including("unrecognized comment type"))
|
|
43
|
+
end
|
|
44
|
+
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.
|
|
4
|
+
version: 0.7.0
|
|
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,11 +163,13 @@ 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
|
|
169
170
|
- lib/commenter/parser/osd_xlsx_parser.rb
|
|
170
171
|
- lib/commenter/parser/track_change_docx_parser.rb
|
|
172
|
+
- lib/commenter/sheet_validator.rb
|
|
171
173
|
- lib/commenter/version.rb
|
|
172
174
|
- schema/iso_comment_2012-03.yaml
|
|
173
175
|
- schema/iso_comment_osd.yaml
|
|
@@ -184,6 +186,7 @@ files:
|
|
|
184
186
|
- spec/commenter/github_integration_spec.rb
|
|
185
187
|
- spec/commenter/github_sync_spec.rb
|
|
186
188
|
- spec/commenter/osd_xlsx_parser_spec.rb
|
|
189
|
+
- spec/commenter/sheet_validator_spec.rb
|
|
187
190
|
- spec/commenter/track_change_docx_parser_spec.rb
|
|
188
191
|
- spec/commenter_spec.rb
|
|
189
192
|
- spec/spec_helper.rb
|