commenter 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 116bd5694762b20a730aa2d67d9950e3acedecf5e0e5d2f95a6d183d376c0e97
4
- data.tar.gz: d1c34482394b44f5195a91268eb3b46f4db58c986afc214a841d74b908f98edb
3
+ metadata.gz: dbcfbae1636268a1f6577337f999f47271e93b7943fd6e9a28a3ddb93603ad04
4
+ data.tar.gz: dcca1817d391d94ce98fbb1469499fd480944cac137ea86ef9c468f1873347fd
5
5
  SHA512:
6
- metadata.gz: 1d755c6bfefe119528e4ddb9e65033263f5c03550d972ae29bd62aaaea8df3fa571439305f80429eefb017856d387e5dde23c18a744ecdfb03d2f77d6a6e486e
7
- data.tar.gz: 6d297683a681dc1918be0db3a5dd63aded21fca0fce0a63799b36e1342d60f927b9690ca9757c8935c74c530edc3205acb7b510e4930360a7f4a99d595a865fc
6
+ metadata.gz: 0fc597c5fe0c6d0f9db6366569d20cad1c93a78170187d3d9a67ba0e8911ba64b355938d11a40fe752b6ccd79ea5e3d7f702af5b61c1848a996962df3d57b1e9
7
+ data.tar.gz: 50e641728f5d07c496388fdc073c2a659ff212f21e8d0d23c44de7c449270052f7bc56dba427bd35f1a68125151d62c61b95a913e7e1aad9e9f4ba4100aebf37
data/CLAUDE.md CHANGED
@@ -50,7 +50,7 @@ Core flow: `Parser` → `CommentSheet` (metadata + `Comment` objects) → YAML /
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
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`.
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,23 @@ 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
+
249
+ Known stages are AWI, WD, CD, DIS, FDIS, PRF, and PUB. A sheet at an
250
+ unusual stage (or with an unrecognized comment type) produces a WARNING,
251
+ which is informational only — a non-zero exit always means errors were
252
+ found.
253
+
237
254
  === Comparing ballots between stages
238
255
 
239
256
  The same comment ID (`DE-001`) recurs at every ballot stage; comparing two
@@ -319,6 +336,8 @@ github:
319
336
  token: "ghp_xxxxxxxxxxxx"
320
337
  milestone:
321
338
  name: "ISO 80000-2 DIS Review"
339
+ # Creates the milestone when it does not exist yet
340
+ create_if_missing: true
322
341
  default_labels: ["comment-review", "iso-standard"]
323
342
  stage_labels:
324
343
  WD: ["working-draft"]
@@ -338,7 +357,7 @@ templates are provided, but you can customize them.
338
357
 
339
358
  The title template (`data/github_issue_title_template.liquid`) supports these variables:
340
359
 
341
- * `stage` - Approval stage (WD/CD/DIS/FDIS/PRF/PUB)
360
+ * `stage` - Approval stage (AWI/WD/CD/DIS/FDIS/PRF/PUB)
342
361
  * `document` - Document identifier
343
362
  * `comment_id` - Comment identifier
344
363
  * `brief_summary` - Generated summary combining locality and description
@@ -435,13 +454,22 @@ Every created issue body also carries a deterministic marker line, e.g.
435
454
  +
436
455
  [source]
437
456
  ----
438
- `urn:commenter:owner/repo:dis:GB-001`
457
+ `urn:commenter:owner/repo:dis:iso1234-2:2019:gb-001`
439
458
  ----
440
459
  +
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.
460
+ The marker includes the sheet's document (compacted and lowercased), so
461
+ the same comment ID (`GB-001`) posted for two different documents of one
462
+ repository never collides. Sheets without a `document` field keep the
463
+ older, document-less marker shape.
464
+
465
+ Duplicate detection searches the issue bodies for this marker first —
466
+ exact and template-independent. It then falls back to the document-less
467
+ marker for issues created before the document segment existed, and to the
468
+ title search for issues created before markers existed at all; those two
469
+ fallbacks are ambiguous when the same comment ID exists for several
470
+ documents, so a fallback hit counts only when the issue itself names the
471
+ sheet's document. Comment numbers already recorded in the comments YAML
472
+ are verified with a direct read before any search runs.
445
473
 
446
474
  ===== Unique ID template
447
475
 
@@ -530,7 +558,10 @@ By default an open issue whose comment has a recorded disposition
530
558
  `github-retrieve` round-trip working, since observations are retrieved from
531
559
  closed issues. Disable with `--no-close-on-disposition`.
532
560
 
533
- The milestone is resolved once per run and reused for every issue.
561
+ The milestone is resolved once per run and reused for every issue. When
562
+ `create_if_missing` is set and no milestone carries that name, the
563
+ milestone is created on first use (honoring `description` and `due_date`
564
+ from the same config block).
534
565
 
535
566
  ==== Retrieving observations from GitHub issues
536
567
 
data/lib/commenter/cli.rb CHANGED
@@ -86,6 +86,30 @@ 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
+ problems.each do |problem|
95
+ where = problem[:comment] ? "#{problem[:comment]}: " : ""
96
+ puts "#{problem[:severity].to_s.upcase}: #{where}#{problem[:message]}"
97
+ end
98
+
99
+ errors = problems.count { |problem| problem[:severity] == :error }
100
+ warnings = problems.count { |problem| problem[:severity] == :warning }
101
+
102
+ # Warnings surface unusual-but-legal sheets (e.g. an early-stage draft)
103
+ # and must not fail CI on their own; only errors are fatal.
104
+ raise Commenter::Error, "#{errors} error(s), #{warnings} warning(s) found in #{input_yaml}" if errors.positive?
105
+
106
+ if problems.empty?
107
+ puts "OK: #{comment_sheet.comments.length} comments, no problems found"
108
+ else
109
+ puts "#{comment_sheet.comments.length} comments valid, #{warnings} warning(s)"
110
+ end
111
+ end
112
+
89
113
  desc "fill INPUT.yaml", "Fill DOCX template from YAML comments"
90
114
  option :output, type: :string, aliases: :o, default: "filled_comments.docx", desc: "Output DOCX file"
91
115
  option :template, type: :string, aliases: :t, desc: "Custom template file"
@@ -100,9 +100,21 @@ module Commenter
100
100
  locality.element = value
101
101
  end
102
102
 
103
+ # Public so every consumer (summaries, issue templates) renders locality
104
+ # through one implementation.
105
+ def locality_summary
106
+ parts = []
107
+ if clause && !clause.strip.empty?
108
+ parts << (clause.strip.match?(/\Aclause\b/i) ? clause : "Clause #{clause}")
109
+ end
110
+ parts << element if element && !element.strip.empty?
111
+ parts << "Line #{line_number}" if line_number && !line_number.strip.empty?
112
+ parts.join(", ")
113
+ end
114
+
103
115
  def brief_summary(max_length = 60)
104
116
  description = comments.to_s.split(/[.!?\n]/).map(&:strip).reject(&:empty?).first.to_s
105
- locality_text = format_locality
117
+ locality_text = locality_summary
106
118
 
107
119
  if description.empty?
108
120
  locality_text.empty? ? "No description" : locality_text
@@ -143,15 +155,5 @@ module Commenter
143
155
  self.github = GithubInfo.new(issue_number: issue_number, issue_url: issue_url,
144
156
  status: status, created_at: created_at)
145
157
  end
146
-
147
- private
148
-
149
- def format_locality
150
- parts = []
151
- parts << "Clause #{clause}" if clause && !clause.strip.empty?
152
- parts << element if element && !element.strip.empty?
153
- parts << "Line #{line_number}" if line_number && !line_number.strip.empty?
154
- parts.join(", ")
155
- end
156
158
  end
157
159
  end
@@ -10,7 +10,6 @@ module Commenter
10
10
  THROTTLE_SECONDS = 1
11
11
  RATE_LIMIT_RETRY_DELAY = 60
12
12
  RATE_LIMIT_MAX_RETRIES = 3
13
- MARKER_PREFIX = "urn:commenter"
14
13
 
15
14
  def initialize(config_path, title_template_path = nil, body_template_path = nil, client: nil)
16
15
  @session = GitHubSession.new(config_path)
@@ -108,7 +107,7 @@ module Commenter
108
107
  "unique_id" => unique_id,
109
108
  "has_observations" => !comment.observations.nil? && !comment.observations.strip.empty?,
110
109
  "has_proposed_change" => !comment.proposed_change.nil? && !comment.proposed_change.strip.empty?,
111
- "locality_summary" => format_locality(comment)
110
+ "locality_summary" => comment.locality_summary
112
111
  }
113
112
  end
114
113
 
@@ -124,14 +123,6 @@ module Commenter
124
123
  @unique_id_template.render(base_variables).strip
125
124
  end
126
125
 
127
- def format_locality(comment)
128
- parts = []
129
- parts << "Clause #{comment.clause}" if comment.clause && !comment.clause.strip.empty?
130
- parts << comment.element if comment.element && !comment.element.strip.empty?
131
- parts << "Line #{comment.line_number}" if comment.line_number && !comment.line_number.strip.empty?
132
- parts.join(", ")
133
- end
134
-
135
126
  def create_issue(comment, comment_sheet, options = {})
136
127
  puts "[GitHubIssueCreator] Creating issue for comment ID: #{comment.id}"
137
128
  begin
@@ -181,16 +172,9 @@ module Commenter
181
172
  end
182
173
  end
183
174
 
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
175
  def body_with_marker(comment, comment_sheet)
192
176
  rendered = @body_template.render(template_variables(comment, comment_sheet))
193
- "#{rendered}\n\n---\n`#{issue_marker(comment, comment_sheet)}`"
177
+ "#{rendered}\n\n---\n`#{IssueMarker.for(comment, comment_sheet, @repo)}`"
194
178
  end
195
179
 
196
180
  def recorded_issue(comment, comment_sheet)
@@ -200,14 +184,14 @@ module Commenter
200
184
  puts "[GitHubIssueCreator] Verifying recorded issue ##{number} for comment ID: #{comment.id}"
201
185
  issue = @github_client.issue(@repo, number)
202
186
  if issue.title.include?(render_unique_id(comment, comment_sheet)) ||
203
- issue.body.to_s.include?(issue_marker(comment, comment_sheet))
187
+ issue.body.to_s.include?(IssueMarker.for(comment, comment_sheet, @repo))
204
188
  issue
205
189
  else
206
- puts "[GitHubIssueCreator] Recorded issue ##{number} does not match, falling back to title search."
190
+ puts "[GitHubIssueCreator] Recorded issue ##{number} does not match, falling back to search."
207
191
  nil
208
192
  end
209
193
  rescue Octokit::NotFound
210
- puts "[GitHubIssueCreator] Recorded issue ##{number} no longer exists, falling back to title search."
194
+ puts "[GitHubIssueCreator] Recorded issue ##{number} no longer exists, falling back to search."
211
195
  nil
212
196
  end
213
197
 
@@ -257,19 +241,29 @@ module Commenter
257
241
  }
258
242
  end
259
243
 
244
+ # Duplicate detection runs in three tiers: the document-qualified marker
245
+ # is exact; the legacy marker and the title match are ambiguous when two
246
+ # documents share a comment id, so those hits count only when the issue
247
+ # itself names this document.
260
248
  def find_existing_issue(comment, comment_sheet)
261
249
  unique_id = render_unique_id(comment, comment_sheet)
262
250
  puts "[GitHubIssueCreator] Searching for existing issue with unique_id: #{unique_id}"
263
251
 
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?
252
+ search_issues("body", IssueMarker.for(comment, comment_sheet, @repo)) ||
253
+ search_issues("body", IssueMarker.legacy_for(comment, comment_sheet, @repo), verify: comment_sheet) ||
254
+ search_issues("title", unique_id, verify: comment_sheet)
255
+ end
269
256
 
270
- query = "repo:#{@repo} in:title \"#{unique_id}\""
271
- results = @github_client.search_issues(query)
272
- results.items.first
257
+ def search_issues(field, term, verify: nil)
258
+ issues = @github_client.search_issues("repo:#{@repo} in:#{field} \"#{term}\"").items
259
+ return issues.first if verify.nil?
260
+ return nil if IssueMarker.document_segment(verify).nil?
261
+
262
+ issues.find { |issue| names_document?(issue, verify.document) }
263
+ end
264
+
265
+ def names_document?(issue, document)
266
+ issue.title.to_s.include?(document) || issue.body.to_s.include?(document)
273
267
  end
274
268
 
275
269
  def determine_labels(comment, comment_sheet)
@@ -307,48 +301,13 @@ module Commenter
307
301
  end
308
302
 
309
303
  def determine_milestone(_comment, _comment_sheet, _options)
310
- # # Check for stage-specific milestone
311
- # if comment_sheet.stage
312
- # stage_milestone = @config.dig("github", "stage_milestones", comment_sheet.stage)
313
- # if stage_milestone
314
- # milestone_number = resolve_milestone_by_name_or_number(stage_milestone)
315
- # return milestone_number if milestone_number
316
- # end
317
- # end
318
-
319
- # Use configured milestone
320
304
  milestone_config = @config.dig("github", "milestone")
321
305
  return nil unless milestone_config
322
306
 
323
- if milestone_config["number"]
324
- puts "[GitHubIssueCreator] Using milestone number: #{milestone_config["number"]}"
325
- milestone_config["number"]
326
- elsif milestone_config["name"]
327
- # Resolved once per run and memoized: resolving per comment caused
328
- # duplicated milestone fetches (see 3a06c3d).
329
- @milestone_number ||= begin
330
- puts "[GitHubIssueCreator] Using milestone name: #{milestone_config["name"]}"
331
- resolve_milestone_by_name_or_number(milestone_config["name"])
332
- end
333
- end
334
- end
335
-
336
- def resolve_milestone_by_name_or_number(milestone_identifier)
337
- # If it's a number, return it directly
338
- return milestone_identifier.to_i if milestone_identifier.to_s.match?(/^\d+$/)
339
-
340
- # Otherwise, search by name
341
- find_milestone_by_name(milestone_identifier)
342
- end
343
-
344
- def find_milestone_by_name(name)
345
- milestones = @github_client.milestones(@repo, state: "all")
346
-
347
- puts "[GitHubIssueCreator] Found #{milestones.size} milestones in repository #{@repo}" if milestones.any?
348
- milestone = milestones.find { |m| m.title == name }
349
- milestone&.number
350
- rescue Octokit::Error
351
- nil
307
+ # Memoized: resolving per comment caused duplicated milestone fetches
308
+ # (see 3a06c3d).
309
+ @milestone_number ||= MilestoneResolver.new(@github_client, @repo, milestone_config).number
310
+ @milestone_number
352
311
  end
353
312
 
354
313
  def update_yaml_with_github_info(yaml_file, comment_sheet, results, options)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commenter
4
+ # Deterministic issue markers for duplicate detection: exact, independent
5
+ # of the user's templates, and stable across runs. The document segment
6
+ # keeps the marker unambiguous when the same comment id exists for several
7
+ # documents in one repository; sheets without a document keep the earlier
8
+ # marker shape.
9
+ module IssueMarker
10
+ PREFIX = "urn:commenter"
11
+
12
+ module_function
13
+
14
+ def for(comment, comment_sheet, repo)
15
+ segments = [PREFIX, repo.to_s.downcase, comment_sheet.stage.to_s.downcase]
16
+ document = document_segment(comment_sheet)
17
+ segments << document unless document.nil?
18
+ segments << comment.id
19
+ segments.join(":")
20
+ end
21
+
22
+ # The marker shape used before markers carried the document. Ambiguous
23
+ # when two documents share a comment id, so callers must verify a hit
24
+ # against the sheet's document (see ExistingIssueFinder).
25
+ def legacy_for(comment, comment_sheet, repo)
26
+ "#{PREFIX}:#{repo.to_s.downcase}:#{comment_sheet.stage.to_s.downcase}:#{comment.id}"
27
+ end
28
+
29
+ def document_segment(comment_sheet)
30
+ document = comment_sheet.document.to_s.gsub(/\s+/, "").downcase
31
+ document.empty? ? nil : document
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commenter
4
+ # Resolves (and optionally creates) the GitHub milestone issues are
5
+ # assigned to: a `number` config passes through; a `name` resolves
6
+ # against the repository; `create_if_missing` mints the milestone on
7
+ # first use, honoring `description` and `due_date`.
8
+ class MilestoneResolver
9
+ def initialize(client, repo, config)
10
+ @client = client
11
+ @repo = repo
12
+ @config = config
13
+ end
14
+
15
+ def number
16
+ return @config["number"] if @config["number"]
17
+ return nil unless @config["name"]
18
+
19
+ @number ||= resolve
20
+ @number ||= create if @config["create_if_missing"]
21
+ @number
22
+ end
23
+
24
+ private
25
+
26
+ def resolve
27
+ return @config["name"].to_i if @config["name"].to_s.match?(/\A\d+\z/)
28
+
29
+ milestones = @client.milestones(@repo, state: "all")
30
+ puts "[GitHubIssueCreator] Found #{milestones.size} milestones in repository #{@repo}" if milestones.any?
31
+ milestones.find { |milestone| milestone.title == @config["name"] }&.number
32
+ rescue Octokit::Error
33
+ nil
34
+ end
35
+
36
+ def create
37
+ options = {}
38
+ options[:description] = @config["description"] if @config["description"]
39
+ if (due = @config["due_date"])
40
+ options[:due_on] = due.include?("T") ? due : "#{due}T00:00:00Z"
41
+ end
42
+ milestone = @client.create_milestone(@repo, @config["name"], options)
43
+ puts "[GitHubIssueCreator] Created milestone ##{milestone.number}: #{@config["name"]}"
44
+ milestone.number
45
+ rescue Octokit::Error => e
46
+ puts "[GitHubIssueCreator] Could not create milestone #{@config["name"].inspect}: #{e.message}"
47
+ nil
48
+ end
49
+ end
50
+ end
@@ -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[AWI 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commenter
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.1"
5
5
  end
data/lib/commenter.rb CHANGED
@@ -14,12 +14,15 @@ 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"
24
+ autoload :IssueMarker, "commenter/issue_marker"
25
+ autoload :MilestoneResolver, "commenter/milestone_resolver"
23
26
  autoload :GitHubIssueRetriever, "commenter/github_issue_retriever"
24
27
  autoload :GitHubSync, "commenter/github_sync"
25
28
  end
@@ -101,3 +101,41 @@ RSpec.describe Commenter::Cli do
101
101
  end
102
102
  end
103
103
  end
104
+
105
+ RSpec.describe Commenter::Cli do
106
+ describe "#validate" do
107
+ def sheet_file(data)
108
+ file = Tempfile.new(["sheet", ".yaml"])
109
+ file.write(data.to_yaml)
110
+ file.close
111
+ file
112
+ end
113
+
114
+ it "passes a sheet at the AWI stage" do
115
+ file = sheet_file({ "version" => "2012-03", "stage" => "AWI",
116
+ "comments" => [{ "id" => "DE-001", "body" => "DE", "comments" => "Text" }] })
117
+
118
+ expect { described_class.start(["validate", file.path]) }
119
+ .to output(a_string_including("OK:", "no problems found")).to_stdout
120
+ file.unlink
121
+ end
122
+
123
+ it "does not fail when only warnings are found" do
124
+ file = sheet_file({ "version" => "2012-03", "stage" => "CUSTOM",
125
+ "comments" => [{ "id" => "DE-001", "body" => "DE", "comments" => "Text" }] })
126
+
127
+ expect { described_class.start(["validate", file.path]) }
128
+ .to output(a_string_including('WARNING: unusual stage "CUSTOM"', "1 warning(s)")).to_stdout
129
+ file.unlink
130
+ end
131
+
132
+ it "fails when errors are found" do
133
+ file = sheet_file({ "version" => "2012-03",
134
+ "comments" => [{ "id" => "DE-001", "body" => "DE" }] })
135
+
136
+ expect { described_class.start(["validate", file.path]) }
137
+ .to raise_error(Commenter::Error, a_string_including("1 error(s)"))
138
+ file.unlink
139
+ end
140
+ end
141
+ end
@@ -81,6 +81,18 @@ RSpec.describe Commenter::Comment do
81
81
  expect(summary).to include("Line 42")
82
82
  end
83
83
 
84
+ it "does not prefix Clause when the clause already names it" do
85
+ comment = described_class.new(
86
+ locality: { clause: "Clause 4 and document" },
87
+ comments: "Comment text"
88
+ )
89
+
90
+ summary = comment.brief_summary
91
+
92
+ expect(summary).to start_with("Clause 4 and document:")
93
+ expect(summary).not_to include("Clause Clause")
94
+ end
95
+
84
96
  it "handles empty locality gracefully" do
85
97
  comment = described_class.new(
86
98
  locality: { clause: "" },
@@ -231,3 +243,19 @@ RSpec.describe Commenter::Comment do
231
243
  end
232
244
  end
233
245
  end
246
+
247
+ RSpec.describe Commenter::Comment do
248
+ describe "#locality_summary" do
249
+ it "formats clause, element, and line number" do
250
+ comment = described_class.new(locality: { clause: "5.1", element: "Table 1", line_number: "42" })
251
+
252
+ expect(comment.locality_summary).to eq("Clause 5.1, Table 1, Line 42")
253
+ end
254
+
255
+ it "does not double the Clause prefix when the clause already names it" do
256
+ comment = described_class.new(locality: { clause: "Clause 4 and document" })
257
+
258
+ expect(comment.locality_summary).to eq("Clause 4 and document")
259
+ end
260
+ end
261
+ end
@@ -17,13 +17,14 @@ RetrieverComment = Struct.new(:body, keyword_init: true)
17
17
  # exercised against an object with the same protocol as Octokit::Client.
18
18
  class FakeGitHubClient
19
19
  attr_accessor :rate_limit_failures, :search_error
20
- attr_reader :created
20
+ attr_reader :created, :created_milestones
21
21
 
22
22
  def initialize(existing_issues = [])
23
23
  @issues = {}
24
24
  existing_issues.each { |issue| @issues[issue.number] = issue }
25
25
  @next_number = @issues.keys.max.to_i
26
26
  @created = []
27
+ @created_milestones = []
27
28
  @rate_limit_failures = 0
28
29
  end
29
30
 
@@ -46,6 +47,12 @@ class FakeGitHubClient
46
47
  [FakeMilestone.new(number: 1, title: "Test Milestone")]
47
48
  end
48
49
 
50
+ def create_milestone(_repo, title, _options = {})
51
+ milestone = FakeMilestone.new(number: 100 + @created_milestones.length + 1, title: title)
52
+ @created_milestones << milestone
53
+ milestone
54
+ end
55
+
49
56
  def create_issue(_repo, title, body, _options = {})
50
57
  if @rate_limit_failures.positive?
51
58
  @rate_limit_failures -= 1
@@ -175,6 +182,36 @@ RSpec.describe Commenter::GitHubIssueCreator do
175
182
  end
176
183
  end
177
184
 
185
+ describe "milestone handling" do
186
+ let(:client) { FakeGitHubClient.new }
187
+ let(:creator) do
188
+ described_class.new(config_file.path, title_template_file.path, body_template_file.path, client: client)
189
+ end
190
+
191
+ it "creates the milestone when create_if_missing is set" do
192
+ config_data["github"]["milestone"] = {
193
+ "name" => "Fresh Milestone",
194
+ "create_if_missing" => true,
195
+ "description" => "Created by specs",
196
+ "due_date" => "2026-12-31"
197
+ }
198
+
199
+ result = creator.create_issues_from_yaml(yaml_file.path, dry_run: true).first
200
+
201
+ expect(result[:milestone]).to eq(101)
202
+ expect(client.created_milestones.map(&:title)).to eq(["Fresh Milestone"])
203
+ end
204
+
205
+ it "leaves the milestone unset when missing without create_if_missing" do
206
+ config_data["github"]["milestone"] = { "name" => "Absent Milestone" }
207
+
208
+ result = creator.create_issues_from_yaml(yaml_file.path, dry_run: true).first
209
+
210
+ expect(result[:milestone]).to be_nil
211
+ expect(client.created_milestones).to be_empty
212
+ end
213
+ end
214
+
178
215
  describe "#create_issues_from_yaml" do
179
216
  let(:creator) { described_class.new(config_file.path, title_template_file.path, body_template_file.path) }
180
217
 
@@ -240,7 +277,7 @@ RSpec.describe Commenter::GitHubIssueCreator do
240
277
  end
241
278
 
242
279
  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")])
280
+ client = FakeGitHubClient.new([seeded_issue(5, "[DIS] US-001: posted earlier (Test Document)")])
244
281
  sheet = write_sheet([yaml_data["comments"].first.merge("github" => { "issue_number" => 99 })])
245
282
  creator = described_class.new(config_file.path, title_template_file.path,
246
283
  body_template_file.path, client: client)
@@ -256,7 +293,7 @@ RSpec.describe Commenter::GitHubIssueCreator do
256
293
 
257
294
  it "falls back to search when the recorded issue title does not match the stage" do
258
295
  client = FakeGitHubClient.new([seeded_issue(7, "[WD] US-001: different stage"),
259
- seeded_issue(5, "[DIS] US-001: posted earlier")])
296
+ seeded_issue(5, "[DIS] US-001: posted earlier (Test Document)")])
260
297
  sheet = write_sheet([yaml_data["comments"].first.merge("github" => { "issue_number" => 7 })])
261
298
  creator = described_class.new(config_file.path, title_template_file.path,
262
299
  body_template_file.path, client: client)
@@ -270,7 +307,7 @@ RSpec.describe Commenter::GitHubIssueCreator do
270
307
  end
271
308
 
272
309
  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")])
310
+ client = FakeGitHubClient.new([seeded_issue(5, "[DIS] US-001: posted earlier (Test Document)")])
274
311
  sheet = write_sheet(yaml_data["comments"])
275
312
  creator = described_class.new(config_file.path, title_template_file.path,
276
313
  body_template_file.path, client: client)
@@ -356,6 +393,14 @@ RSpec.describe Commenter::GitHubIssueCreator do
356
393
  file
357
394
  end
358
395
 
396
+ it "does not double the Clause prefix in locality_summary" do
397
+ yaml_data["comments"].first["locality"]["clause"] = "Clause 4 and document"
398
+
399
+ result = creator.create_issues_from_yaml(yaml_file.path, dry_run: true).first
400
+
401
+ expect(result[:title]).to include("Clause 4 and document|")
402
+ end
403
+
359
404
  it "exposes sheet, comment, and computed variables to templates" do
360
405
  result = creator.create_issues_from_yaml(yaml_file.path, dry_run: true).first
361
406
  variables = result[:title].split("|")
@@ -505,12 +550,57 @@ RSpec.describe Commenter::GitHubIssueCreator do
505
550
  file.unlink
506
551
  end
507
552
 
508
- def write_yaml_with_github(issue_number)
553
+ it "embeds the document in the marker when the sheet has one" do
554
+ client = FakeGitHubClient.new
555
+ creator = described_class.new(config_file.path, client: client)
556
+
557
+ file = write_yaml_with_github(nil, document: "ISO 80000-2:2019")
558
+ results = creator.create_issues_from_yaml(file)
559
+
560
+ expect(results.first[:status]).to eq(:created)
561
+ expect(client.created.first.body)
562
+ .to match(%r{urn:commenter:test-org/test-repo:dis:iso80000-2:2019:US-001})
563
+ file.unlink
564
+ end
565
+
566
+ it "creates a new issue when a same-id issue exists for another document (legacy marker)" do
567
+ other = FakeIssue.new(number: 9, title: "[DIS] US-001 (Doc A)",
568
+ body: "x `urn:commenter:test-org/test-repo:dis:US-001`",
569
+ html_url: "u", state: "open", created_at: "2026-01-01T00:00:00Z")
570
+ client = FakeGitHubClient.new([other])
571
+ creator = described_class.new(config_file.path, client: client)
572
+
573
+ file = write_yaml_with_github(nil, document: "Doc B")
574
+ results = creator.create_issues_from_yaml(file)
575
+
576
+ expect(results.first[:status]).to eq(:created)
577
+ expect(results.first[:issue_number]).to eq(10)
578
+ file.unlink
579
+ end
580
+
581
+ it "skips via the legacy marker only when the existing issue names this document" do
582
+ legacy = FakeIssue.new(number: 9, title: "[DIS] US-001 Doc B",
583
+ body: "x `urn:commenter:test-org/test-repo:dis:US-001`",
584
+ html_url: "u", state: "open", created_at: "2026-01-01T00:00:00Z")
585
+ client = FakeGitHubClient.new([legacy])
586
+ creator = described_class.new(config_file.path, client: client)
587
+
588
+ file = write_yaml_with_github(nil, document: "Doc B")
589
+ results = creator.create_issues_from_yaml(file)
590
+
591
+ expect(results.first[:status]).to eq(:skipped)
592
+ expect(results.first[:issue_number]).to eq(9)
593
+ file.unlink
594
+ end
595
+
596
+ def write_yaml_with_github(issue_number, document: nil)
509
597
  github = issue_number ? { "issue_number" => issue_number, "status" => "open" } : nil
598
+ sheet = { "version" => "2012-03", "stage" => "DIS",
599
+ "comments" => [{ "id" => "US-001", "body" => "US", "comments" => "Text",
600
+ "github" => github }.compact] }
601
+ sheet["document"] = document unless document.nil?
510
602
  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)
603
+ file.write(sheet.to_yaml)
514
604
  file.close
515
605
  file
516
606
  end
@@ -0,0 +1,48 @@
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 "accepts the AWI stage without a warning" do
26
+ expect(described_class.call(sheet([comment("DE-001")], stage: "AWI"))).to eq([])
27
+ end
28
+
29
+ it "reports missing ids, missing text, and duplicate ids as errors" do
30
+ problems = described_class.call(sheet([
31
+ comment("", text: "No id"),
32
+ comment("DE-001"),
33
+ comment("DE-001"),
34
+ comment("DE-002", text: "")
35
+ ]))
36
+
37
+ errors = problems.select { |problem| problem[:severity] == :error }
38
+ expect(errors.map { |problem| problem[:message] }).to include("missing id", "missing comment text",
39
+ "duplicate comment id DE-001")
40
+ end
41
+
42
+ it "warns about unrecognized comment types without failing" do
43
+ problems = described_class.call(sheet([comment("DE-001", type: "ge/te")]))
44
+
45
+ expect(problems).to contain_exactly(severity: :warning, comment: "DE-001",
46
+ message: a_string_including("unrecognized comment type"))
47
+ end
48
+ 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.1
4
+ version: 0.7.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-09-08 00:00:00.000000000 Z
11
+ date: 2026-11-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -166,9 +166,12 @@ files:
166
166
  - lib/commenter/github_issue_retriever.rb
167
167
  - lib/commenter/github_session.rb
168
168
  - lib/commenter/github_sync.rb
169
+ - lib/commenter/issue_marker.rb
170
+ - lib/commenter/milestone_resolver.rb
169
171
  - lib/commenter/parser.rb
170
172
  - lib/commenter/parser/osd_xlsx_parser.rb
171
173
  - lib/commenter/parser/track_change_docx_parser.rb
174
+ - lib/commenter/sheet_validator.rb
172
175
  - lib/commenter/version.rb
173
176
  - schema/iso_comment_2012-03.yaml
174
177
  - schema/iso_comment_osd.yaml
@@ -185,6 +188,7 @@ files:
185
188
  - spec/commenter/github_integration_spec.rb
186
189
  - spec/commenter/github_sync_spec.rb
187
190
  - spec/commenter/osd_xlsx_parser_spec.rb
191
+ - spec/commenter/sheet_validator_spec.rb
188
192
  - spec/commenter/track_change_docx_parser_spec.rb
189
193
  - spec/commenter_spec.rb
190
194
  - spec/spec_helper.rb