commenter 0.7.0 → 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: 94c3f6c75b5bf2bc60da48e3b38f3bfcbc17eb259d29834bc9b4a496da1a70fb
4
- data.tar.gz: 5533dfb8fab486205a4f6a164ee5604561354d116e36018fc487913d036cf56a
3
+ metadata.gz: dbcfbae1636268a1f6577337f999f47271e93b7943fd6e9a28a3ddb93603ad04
4
+ data.tar.gz: dcca1817d391d94ce98fbb1469499fd480944cac137ea86ef9c468f1873347fd
5
5
  SHA512:
6
- metadata.gz: 66614ec628ece4764cb10b33fc6213de4dad08fc72dba5a89a2c0cebda64848795f7a7372b0ed8b44b081ec4a8daa4b80f5605867986672a32ac7c26f2778545
7
- data.tar.gz: 866aa00a35e504930f882ca391d335d7bb7602e77da086148712797e9772da0683ae5055350aaf2515a4ea8b620793c3780e2c0dae8fe63486e55410a8c19e07
6
+ metadata.gz: 0fc597c5fe0c6d0f9db6366569d20cad1c93a78170187d3d9a67ba0e8911ba64b355938d11a40fe752b6ccd79ea5e3d7f702af5b61c1848a996962df3d57b1e9
7
+ data.tar.gz: 50e641728f5d07c496388fdc073c2a659ff212f21e8d0d23c44de7c449270052f7bc56dba427bd35f1a68125151d62c61b95a913e7e1aad9e9f4ba4100aebf37
data/README.adoc CHANGED
@@ -246,6 +246,11 @@ non-zero when errors are found, so it fits CI and pre-flight checks:
246
246
  commenter validate ballot.yaml
247
247
  ----
248
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
+
249
254
  === Comparing ballots between stages
250
255
 
251
256
  The same comment ID (`DE-001`) recurs at every ballot stage; comparing two
@@ -331,6 +336,8 @@ github:
331
336
  token: "ghp_xxxxxxxxxxxx"
332
337
  milestone:
333
338
  name: "ISO 80000-2 DIS Review"
339
+ # Creates the milestone when it does not exist yet
340
+ create_if_missing: true
334
341
  default_labels: ["comment-review", "iso-standard"]
335
342
  stage_labels:
336
343
  WD: ["working-draft"]
@@ -350,7 +357,7 @@ templates are provided, but you can customize them.
350
357
 
351
358
  The title template (`data/github_issue_title_template.liquid`) supports these variables:
352
359
 
353
- * `stage` - Approval stage (WD/CD/DIS/FDIS/PRF/PUB)
360
+ * `stage` - Approval stage (AWI/WD/CD/DIS/FDIS/PRF/PUB)
354
361
  * `document` - Document identifier
355
362
  * `comment_id` - Comment identifier
356
363
  * `brief_summary` - Generated summary combining locality and description
@@ -447,13 +454,22 @@ Every created issue body also carries a deterministic marker line, e.g.
447
454
  +
448
455
  [source]
449
456
  ----
450
- `urn:commenter:owner/repo:dis:GB-001`
457
+ `urn:commenter:owner/repo:dis:iso1234-2:2019:gb-001`
451
458
  ----
452
459
  +
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.
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.
457
473
 
458
474
  ===== Unique ID template
459
475
 
@@ -542,7 +558,10 @@ By default an open issue whose comment has a recorded disposition
542
558
  `github-retrieve` round-trip working, since observations are retrieved from
543
559
  closed issues. Disable with `--no-close-on-disposition`.
544
560
 
545
- 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).
546
565
 
547
566
  ==== Retrieving observations from GitHub issues
548
567
 
data/lib/commenter/cli.rb CHANGED
@@ -91,17 +91,23 @@ module Commenter
91
91
  comment_sheet = CommentSheet.from_yaml(File.read(input_yaml))
92
92
  problems = SheetValidator.call(comment_sheet)
93
93
 
94
- if problems.empty?
95
- puts "OK: #{comment_sheet.comments.length} comments, no problems found"
96
- return
97
- end
98
-
99
94
  problems.each do |problem|
100
95
  where = problem[:comment] ? "#{problem[:comment]}: " : ""
101
96
  puts "#{problem[:severity].to_s.upcase}: #{where}#{problem[:message]}"
102
97
  end
98
+
103
99
  errors = problems.count { |problem| problem[:severity] == :error }
104
- raise Commenter::Error, "#{errors} error(s) found in #{input_yaml}"
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
105
111
  end
106
112
 
107
113
  desc "fill INPUT.yaml", "Fill DOCX template from YAML comments"
@@ -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
@@ -8,7 +8,7 @@ module Commenter
8
8
  # :message. An empty list means the sheet is valid.
9
9
  module SheetValidator
10
10
  VERSIONS = %w[2012-03 osd].freeze
11
- STAGES = %w[WD CD DIS FDIS PRF PUB].freeze
11
+ STAGES = %w[AWI WD CD DIS FDIS PRF PUB].freeze
12
12
 
13
13
  module_function
14
14
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commenter
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.1"
5
5
  end
data/lib/commenter.rb CHANGED
@@ -21,6 +21,8 @@ module Commenter
21
21
  autoload :Parser, "commenter/parser"
22
22
  autoload :Filler, "commenter/filler"
23
23
  autoload :GitHubIssueCreator, "commenter/github_integration"
24
+ autoload :IssueMarker, "commenter/issue_marker"
25
+ autoload :MilestoneResolver, "commenter/milestone_resolver"
24
26
  autoload :GitHubIssueRetriever, "commenter/github_issue_retriever"
25
27
  autoload :GitHubSync, "commenter/github_sync"
26
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
@@ -22,6 +22,10 @@ RSpec.describe Commenter::SheetValidator do
22
22
  expect(problems).to include(severity: :warning, message: a_string_including("unusual stage"))
23
23
  end
24
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
+
25
29
  it "reports missing ids, missing text, and duplicate ids as errors" do
26
30
  problems = described_class.call(sheet([
27
31
  comment("", text: "No id"),
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.7.0
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,6 +166,8 @@ 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