commenter 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90d4268c6a071ae365af24ef05a5c19dcdfd6fe03545c05a84767f91f70dc411
4
- data.tar.gz: b44468b6f9cc18e0726c1df1c1433247e8fc19f6ff9fafc4c3f0eff341a21785
3
+ metadata.gz: e569c1e1d181c27078d97e4bcaa8a63c54b055aa7aff5dff88646be1237c6a65
4
+ data.tar.gz: 5d156b1e49c82c9b8002e47f7ab870d98e133622b4830768e02b9bf32c2db993
5
5
  SHA512:
6
- metadata.gz: 00d4838eadd5c0751199832ff6401a753b2bc4c27cd94d02f6c0d2bab6392a629c216c6cc350766c0d35920f871b4e2bd74102967460e370ebc15fc005a17e9c
7
- data.tar.gz: '09420029845d4e6b776eeb40b726326486a409dd8f0ec33be792dcc193e30f5d5774e99777cd64e85c31a5137a69d2648eac4ab06bde2a5ea93a7d2569d0e90a'
6
+ metadata.gz: 66daebbb66df3c881828bccd0a36c8cb0e1c524d74c19efef579770f707a366c2f48a51b2871041dd3519db0b26ad4431f0065395b02d85685f08eed9862e39e
7
+ data.tar.gz: 6ea9b41315c94d87c8356350be9b55235d1dfd07e341bbbc133a6feea27b807f36ac9df05bc32430ed314c73c5b5c8a25d9e1ff5066beb0f36f90032c3b9330a
data/.rubocop.yml CHANGED
@@ -16,3 +16,7 @@ Metrics/ModuleLength:
16
16
  Metrics/BlockLength:
17
17
  Exclude:
18
18
  - "spec/**/*"
19
+
20
+ Metrics/ClassLength:
21
+ Exclude:
22
+ - "lib/commenter/cli.rb"
data/CONTEXT.md CHANGED
@@ -23,5 +23,13 @@ keep this current when concepts sharpen.
23
23
  from ISO/CS editors; also carries reviewer remark threads.
24
24
  - **OSD** — ISO Online Standards Development; its XLSX comment exports come
25
25
  in resolved and unresolved variants.
26
+ - **Stage Comparison** — how the comment set evolved between two ballots
27
+ (new, withdrawn, repeated, revised; resolved = disposition recorded).
28
+ `BallotDiff`.
29
+ - **GitHub Sync** — reconciliation of the comment YAML with GitHub issues
30
+ (YAML as source of truth): create missing, refresh content per conflict
31
+ policy, close dispositioned issues. `GitHubSync`.
26
32
  - **Unique ID** — stage-aware identity of a comment's GitHub issue
27
33
  (`[DIS] GB-001` by default), used for duplicate detection.
34
+ - **Out of scope** — CEN/CENELEC comment exports (decided 2026-08-29: we
35
+ don't have CEN; do not propose a CEN adapter again).
data/README.adoc CHANGED
@@ -234,6 +234,33 @@ 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
+ === Comparing ballots between stages
238
+
239
+ The same comment ID (`DE-001`) recurs at every ballot stage; comparing two
240
+ ballot sheets shows how the set evolved between CD and DIS:
241
+
242
+ [source,shell]
243
+ ----
244
+ commenter diff cd-ballot.yaml dis-ballot.yaml
245
+ ----
246
+
247
+ Comments are matched by ID and classified as new, withdrawn, repeated
248
+ (unchanged text), or revised; a comment whose later version carries a
249
+ disposition counts as resolved. Markdown (default) or `--format yaml`.
250
+
251
+ === Exporting the final disposition document
252
+
253
+ One command for the ballot's last step — pull the observations recorded in
254
+ GitHub issues and write the completed comment sheet:
255
+
256
+ [source,shell]
257
+ ----
258
+ commenter export comments.yaml --config github_config.yaml -o disposition.docx --shading
259
+ ----
260
+
261
+ Retrieves observations from closed issues (updating the YAML), then fills the
262
+ template with sheet metadata, comments, observations, and status shading.
263
+
237
264
  === Filling DOCX templates from YAML
238
265
 
239
266
  This gem contains a command-line utility to fill a DOCX template with comments
@@ -464,6 +491,35 @@ The `unique_id` is available as a variable in title and body templates:
464
491
 
465
492
  This renders to: `[DIS] GB-001: Clause 5.1 summary (ISO/DIS 2533)`
466
493
 
494
+ ==== Synchronizing with GitHub issues
495
+
496
+ `github-create` is one-shot: it creates missing issues and skips existing
497
+ ones. `github-sync` keeps the YAML and the issues in step, treating the YAML
498
+ as the source of truth:
499
+
500
+ [source,shell]
501
+ ----
502
+ commenter github-sync --config github_config.yaml comments.yaml
503
+ ----
504
+
505
+ For each comment it creates a missing issue, refreshes issue content from
506
+ the YAML, and reconciles issue state. Two policies govern the parts that
507
+ can collide:
508
+
509
+ `--conflict yaml` (default):: Re-render the issue title and body from the
510
+ YAML when they differ (YAML wins).
511
+ `--conflict github`:: Leave differing issue content untouched (edits made
512
+ on GitHub win).
513
+ `--conflict skip`:: Take no action on differing content; the result is
514
+ reported as a conflict.
515
+
516
+ By default an open issue whose comment has a recorded disposition
517
+ (`observations`) is closed — that is what keeps the
518
+ `github-retrieve` round-trip working, since observations are retrieved from
519
+ closed issues. Disable with `--no-close-on-disposition`.
520
+
521
+ The milestone is resolved once per run and reused for every issue.
522
+
467
523
  ==== Retrieving observations from GitHub issues
468
524
 
469
525
  ===== Basic usage
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commenter
4
+ # Stage-to-stage comparison of two ballots: how the comment set evolved
5
+ # between e.g. CD and DIS — new comments, withdrawn comments, repeated
6
+ # unchanged comments, and revised ones. Comments are matched by ID (member
7
+ # body + number), which repeats across stages.
8
+ module BallotDiff
9
+ module_function
10
+
11
+ def diff(before_sheet, after_sheet)
12
+ before_comments = indexed(before_sheet)
13
+ after_comments = indexed(after_sheet)
14
+
15
+ result = { new: [], withdrawn: [], repeated: [], revised: [], resolved: [] }
16
+ after_comments.each do |id, after|
17
+ before = before_comments[id]
18
+ if before.nil?
19
+ result[:new] << id
20
+ elsif identical?(before, after)
21
+ result[:repeated] << id
22
+ else
23
+ result[:revised] << id
24
+ end
25
+ result[:resolved] << id if dispositioned?(after)
26
+ end
27
+ before_comments.each_key { |id| result[:withdrawn] << id unless after_comments.key?(id) }
28
+
29
+ result.each_value(&:sort!)
30
+ result
31
+ end
32
+
33
+ def to_markdown(before_sheet, after_sheet)
34
+ data = diff(before_sheet, after_sheet)
35
+ lines = []
36
+ lines << "| Category | Count | Comments |"
37
+ lines << "|----------|-------|----------|"
38
+ data.each do |category, ids|
39
+ label = category.to_s.tr("_", " ").capitalize
40
+ listed = ids.length <= 10 ? ids.join(", ") : "#{ids.first(10).join(", ")} …"
41
+ lines << "| #{label} | #{ids.length} | #{listed} |"
42
+ end
43
+ lines << ""
44
+ lines << "Resolved (disposition recorded in the later stage): #{data[:resolved].length}"
45
+ lines.join("\n")
46
+ end
47
+
48
+ def indexed(sheet)
49
+ sheet.comments.each_with_object({}) do |comment, index|
50
+ key = comment.id.to_s
51
+ index[key] = comment unless key.empty?
52
+ end
53
+ end
54
+
55
+ def identical?(before, after)
56
+ before.comments == after.comments && before.proposed_change == after.proposed_change
57
+ end
58
+
59
+ def dispositioned?(comment)
60
+ observation = comment.observations.to_s.strip
61
+ !observation.empty? || DispositionStatus.match(comment.resolution_status)
62
+ end
63
+ end
64
+ end
data/lib/commenter/cli.rb CHANGED
@@ -65,6 +65,27 @@ module Commenter
65
65
  end
66
66
  end
67
67
 
68
+ desc "diff BEFORE.yaml AFTER.yaml", "Compare two ballots (e.g. CD vs DIS) by comment ID"
69
+ option :output, type: :string, aliases: :o, desc: "Output file (default: stdout)"
70
+ option :format, type: :string, default: "markdown", enum: %w[markdown yaml], desc: "Report format"
71
+ def diff(before_yaml, after_yaml)
72
+ before_sheet = CommentSheet.from_yaml(File.read(before_yaml))
73
+ after_sheet = CommentSheet.from_yaml(File.read(after_yaml))
74
+
75
+ report = if options[:format] == "yaml"
76
+ BallotDiff.diff(before_sheet, after_sheet).to_yaml
77
+ else
78
+ BallotDiff.to_markdown(before_sheet, after_sheet)
79
+ end
80
+
81
+ if options[:output]
82
+ File.write(options[:output], report)
83
+ puts "Wrote comparison to #{options[:output]}"
84
+ else
85
+ puts report
86
+ end
87
+ end
88
+
68
89
  desc "fill INPUT.yaml", "Fill DOCX template from YAML comments"
69
90
  option :output, type: :string, aliases: :o, default: "filled_comments.docx", desc: "Output DOCX file"
70
91
  option :template, type: :string, aliases: :t, desc: "Custom template file"
@@ -89,6 +110,27 @@ module Commenter
89
110
  puts "Filled template to #{output_docx}"
90
111
  end
91
112
 
113
+ desc "export INPUT.yaml", "Retrieve observations from GitHub issues and write the final disposition DOCX"
114
+ option :config, type: :string, aliases: :c, required: true, desc: "GitHub configuration YAML file"
115
+ option :output, type: :string, aliases: :o, default: "disposition_comments.docx", desc: "Output DOCX file"
116
+ option :template, type: :string, aliases: :t, desc: "Custom template file"
117
+ option :shading, type: :boolean, aliases: :s, desc: "Apply status-based shading"
118
+ option :include_open, type: :boolean, desc: "Include observations from open issues (not recommended)"
119
+ def export(input_yaml)
120
+ retriever = GitHubIssueRetriever.new(options[:config])
121
+ retriever.retrieve_observations_from_yaml(input_yaml, include_open: options[:include_open])
122
+
123
+ comment_sheet = CommentSheet.from_yaml(File.read(input_yaml))
124
+ raise "No comments found in YAML file" if comment_sheet.comments.empty?
125
+
126
+ template_path = options[:template] || File.join(__dir__, "../../data/iso_comment_template_2012-03.docx")
127
+ fill_options = options.merge(
128
+ date: comment_sheet.date, document: comment_sheet.document, project: comment_sheet.project
129
+ ).compact
130
+ Filler.new.fill(template_path, options[:output], comment_sheet.comments, fill_options)
131
+ puts "Retrieved observations and wrote disposition sheet to #{options[:output]}"
132
+ end
133
+
92
134
  desc "github-create INPUT.yaml", "Create GitHub issues from comments"
93
135
  option :config, type: :string, aliases: :c, required: true, desc: "GitHub configuration YAML file"
94
136
  option :output, type: :string, aliases: :o, desc: "Output YAML file (default: update original)"
@@ -160,6 +202,66 @@ module Commenter
160
202
  exit 1
161
203
  end
162
204
 
205
+ desc "github-sync INPUT.yaml", "Synchronize comments with GitHub issues (YAML is the source of truth)"
206
+ option :config, type: :string, aliases: :c, required: true, desc: "GitHub configuration YAML file"
207
+ option :output, type: :string, aliases: :o, desc: "Output YAML file (default: update original)"
208
+ option :stage, type: :string, desc: "Override approval stage (WD/CD/DIS/FDIS/PRF/PUB)"
209
+ option :milestone, type: :string, desc: "Override milestone name or number"
210
+ option :assignee, type: :string, desc: "Override assignee GitHub handle"
211
+ option :title_template, type: :string, desc: "Custom title template"
212
+ option :body_template, type: :string, desc: "Custom body template"
213
+ option :dry_run, type: :boolean, desc: "Preview sync actions without applying them"
214
+ option :conflict, type: :string, default: "yaml", enum: %w[yaml github skip],
215
+ desc: "Conflict policy: yaml re-renders the issue, " \
216
+ "github keeps the issue content, skip reports and leaves it"
217
+ option :close_on_disposition, type: :boolean, default: true,
218
+ desc: "Close open issues whose comment has a recorded " \
219
+ "disposition"
220
+ def github_sync(input_yaml)
221
+ sync = GitHubSync.new(options[:config], options[:title_template], options[:body_template])
222
+
223
+ sync_options = {
224
+ stage: options[:stage],
225
+ milestone: options[:milestone],
226
+ assignee: options[:assignee],
227
+ conflict: options[:conflict],
228
+ close_on_disposition: options[:close_on_disposition],
229
+ dry_run: options[:dry_run],
230
+ output: options[:output]
231
+ }.compact
232
+ results = sync.sync(input_yaml, sync_options)
233
+
234
+ if options[:dry_run]
235
+ puts "DRY RUN - Planned sync actions:"
236
+ puts "=" * 50
237
+ results.each do |result|
238
+ issue = result[:issue_number] ? "issue ##{result[:issue_number]}" : "no issue yet"
239
+ puts "#{result[:comment_id]} (#{issue}): #{result[:actions].join(", ")}"
240
+ end
241
+ puts "-" * 30
242
+ puts "Based on the issue state last recorded in the YAML; run without --dry-run for a live pass."
243
+ else
244
+ puts "GitHub sync results:"
245
+ puts "=" * 40
246
+ results.each do |result|
247
+ case result[:status]
248
+ when :created then puts "\u2713 #{result[:comment_id]}: Created issue ##{result[:issue_number]} " \
249
+ "(#{result[:issue_url]})"
250
+ when :updated then puts "\u21bb #{result[:comment_id]}: Updated issue ##{result[:issue_number]} " \
251
+ "(#{result[:actions].join(", ")})"
252
+ when :closed then puts "\u2713 #{result[:comment_id]}: Closed issue ##{result[:issue_number]} (disposition recorded)"
253
+ when :updated_and_closed then puts "\u21bb\u2713 #{result[:comment_id]}: Updated and closed issue ##{result[:issue_number]}"
254
+ when :unchanged then puts "- #{result[:comment_id]}: Issue ##{result[:issue_number]} in sync"
255
+ when :error then puts "\u2717 #{result[:comment_id]}: Error - #{result[:message]}"
256
+ end
257
+ end
258
+ puts "Updated YAML file: #{options[:output] || input_yaml}"
259
+ end
260
+ rescue StandardError => e
261
+ puts "Error: #{e.message}"
262
+ exit 1
263
+ end
264
+
163
265
  desc "github-retrieve INPUT.yaml", "Retrieve observations from GitHub issues"
164
266
  option :config, type: :string, aliases: :c, required: true, desc: "GitHub configuration YAML file"
165
267
  option :output, type: :string, aliases: :o, desc: "Output YAML file (default: update original)"
@@ -250,8 +250,12 @@ module Commenter
250
250
  puts "[GitHubIssueCreator] Using milestone number: #{milestone_config["number"]}"
251
251
  milestone_config["number"]
252
252
  elsif milestone_config["name"]
253
- puts "[GitHubIssueCreator] Using milestone name: #{milestone_config["name"]}"
254
- resolve_milestone_by_name_or_number(milestone_config["name"])
253
+ # Resolved once per run and memoized: resolving per comment caused
254
+ # duplicated milestone fetches (see 3a06c3d).
255
+ @milestone_number ||= begin
256
+ puts "[GitHubIssueCreator] Using milestone name: #{milestone_config["name"]}"
257
+ resolve_milestone_by_name_or_number(milestone_config["name"])
258
+ end
255
259
  end
256
260
  end
257
261
 
@@ -276,13 +280,14 @@ module Commenter
276
280
  def update_yaml_with_github_info(yaml_file, comment_sheet, results, options)
277
281
  # Update comments with GitHub information
278
282
  results.each do |result|
279
- next unless result[:status] == :created
283
+ next unless result[:issue_number]
280
284
 
281
285
  comment = comment_sheet.comments.find { |c| c.id == result[:comment_id] }
282
286
  next unless comment
283
287
 
284
288
  comment.record_github_issue(issue_number: result[:issue_number], issue_url: result[:issue_url],
285
- status: "open", created_at: Time.now.utc.iso8601)
289
+ status: result[:status] == :created ? "open" : comment.github_status,
290
+ created_at: comment.github_created_at || Time.now.utc.iso8601)
286
291
  end
287
292
 
288
293
  # Write updated YAML
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commenter
4
+ # Synchronizes the comment YAML with GitHub issues, treating the YAML as
5
+ # the source of truth: comments without an issue get one, issue content is
6
+ # refreshed from the YAML (per the conflict policy), and issues whose
7
+ # comment has a recorded disposition can be closed.
8
+ class GitHubSync < GitHubIssueCreator
9
+ # Returns the sync actions for one comment by comparing the rendered
10
+ # issue content against the issue's current state. Pure — no network —
11
+ # so the conflict matrix is directly specifiable.
12
+ #
13
+ # conflict policy:
14
+ # yaml - re-render title/body over the issue (YAML wins)
15
+ # github - leave differing issue content untouched (GitHub wins)
16
+ # skip - take no action on differing content, report the conflict
17
+ def self.reconcile_actions(issue:, rendered:, disposition:, conflict: "yaml",
18
+ close_on_disposition: true)
19
+ actions = []
20
+ differing = [rendered[:title].to_s.strip, rendered[:body].to_s.strip] !=
21
+ [issue[:title].to_s.strip, issue[:body].to_s.strip]
22
+
23
+ if differing
24
+ case conflict
25
+ when "yaml" then actions << :update_content
26
+ when "github" then actions << :keep_github_content
27
+ when "skip" then actions << :conflict
28
+ end
29
+ end
30
+
31
+ actions << :close if close_on_disposition && issue[:state] == "open" && disposition && !disposition.strip.empty?
32
+
33
+ actions
34
+ end
35
+
36
+ def sync(yaml_file, options = {})
37
+ comment_sheet = CommentSheet.from_yaml(File.read(yaml_file))
38
+ comment_sheet.stage = options[:stage] if options[:stage]
39
+
40
+ results = comment_sheet.comments.map do |comment|
41
+ options[:dry_run] ? plan(comment, options) : reconcile(comment, comment_sheet, options)
42
+ end
43
+
44
+ update_yaml_with_github_info(yaml_file, comment_sheet, results, options) unless options[:dry_run]
45
+ results
46
+ end
47
+
48
+ private
49
+
50
+ # Dry-run preview, offline: comments with a known issue are planned from
51
+ # the last state recorded in the YAML, not from a live fetch.
52
+ def plan(comment, options)
53
+ return { comment_id: comment.id, actions: [:create] } unless comment.has_github_issue?
54
+
55
+ actions = [:reconcile]
56
+ if close_on_disposition?(options) && comment.github_status == "open" &&
57
+ comment.observations && !comment.observations.strip.empty?
58
+ actions << :close
59
+ end
60
+ { comment_id: comment.id, issue_number: comment.github_issue_number, actions: actions }
61
+ end
62
+
63
+ def reconcile(comment, comment_sheet, options)
64
+ issue = find_existing_issue(comment, comment_sheet)
65
+ return create_issue(comment, comment_sheet, options) unless issue
66
+
67
+ title = @title_template.render(template_variables(comment, comment_sheet))
68
+ body = @body_template.render(template_variables(comment, comment_sheet))
69
+ actions = self.class.reconcile_actions(
70
+ issue: { title: issue.title, body: issue.body, state: issue.state },
71
+ rendered: { title: title, body: body },
72
+ disposition: comment.observations,
73
+ conflict: options[:conflict] || "yaml",
74
+ close_on_disposition: close_on_disposition?(options)
75
+ )
76
+
77
+ apply(comment, issue, title, body, actions)
78
+ rescue Octokit::Error => e
79
+ { comment_id: comment.id, status: :error, message: e.message }
80
+ end
81
+
82
+ def apply(comment, issue, title, body, actions)
83
+ result = { comment_id: comment.id, issue_number: issue.number, status: :unchanged, actions: actions }
84
+ comment.record_github_issue(issue_number: issue.number, issue_url: issue.html_url, status: issue.state)
85
+
86
+ if actions.include?(:update_content)
87
+ @github_client.update_issue(@repo, issue.number, title: title, body: body)
88
+ result[:status] = :updated
89
+ end
90
+
91
+ if actions.include?(:close)
92
+ @github_client.close_issue(@repo, issue.number)
93
+ comment.github.status = "closed"
94
+ comment.github.updated_at = Time.now.utc.iso8601
95
+ result[:status] = actions.include?(:update_content) ? :updated_and_closed : :closed
96
+ end
97
+
98
+ result
99
+ end
100
+
101
+ def close_on_disposition?(options)
102
+ options.fetch(:close_on_disposition, true)
103
+ end
104
+ end
105
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Commenter
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/commenter.rb CHANGED
@@ -13,6 +13,7 @@ module Commenter
13
13
  autoload :DispositionStatus, "commenter/disposition_status"
14
14
  autoload :Ballot, "commenter/ballot"
15
15
  autoload :BallotReport, "commenter/ballot_report"
16
+ autoload :BallotDiff, "commenter/ballot_diff"
16
17
  autoload :Comment, "commenter/comment"
17
18
  autoload :CommentSheet, "commenter/comment_sheet"
18
19
  autoload :GitHubSession, "commenter/github_session"
@@ -20,4 +21,5 @@ module Commenter
20
21
  autoload :Filler, "commenter/filler"
21
22
  autoload :GitHubIssueCreator, "commenter/github_integration"
22
23
  autoload :GitHubIssueRetriever, "commenter/github_integration"
24
+ autoload :GitHubSync, "commenter/github_sync"
23
25
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Commenter::BallotDiff do
6
+ def sheet(comments)
7
+ Commenter::CommentSheet.new(comments: comments)
8
+ end
9
+
10
+ def comment(id, text: "Text for #{id}", observations: nil)
11
+ Commenter::Comment.new(id: id, body: id.split("-").first, comments: text, observations: observations)
12
+ end
13
+
14
+ let(:cd) do
15
+ sheet([
16
+ comment("DE-001"),
17
+ comment("DE-002", text: "Old wording"),
18
+ comment("US-001")
19
+ ])
20
+ end
21
+
22
+ let(:dis) do
23
+ sheet([
24
+ comment("DE-001"),
25
+ comment("DE-002", text: "Revised wording"),
26
+ comment("US-001", observations: "Accepted."),
27
+ comment("JP-001")
28
+ ])
29
+ end
30
+
31
+ it "classifies new, withdrawn, repeated, and revised comments" do
32
+ result = described_class.diff(cd, dis)
33
+
34
+ expect(result[:new]).to eq(["JP-001"])
35
+ expect(result[:withdrawn]).to eq([]) # US-001 present in both
36
+ expect(result[:repeated]).to eq(%w[DE-001 US-001])
37
+ expect(result[:revised]).to eq(["DE-002"])
38
+ end
39
+
40
+ it "counts comments with a disposition in the later stage as resolved" do
41
+ result = described_class.diff(cd, dis)
42
+
43
+ expect(result[:resolved]).to eq(["US-001"])
44
+ end
45
+
46
+ it "reports withdrawn comments" do
47
+ result = described_class.diff(dis, cd)
48
+
49
+ expect(result[:withdrawn]).to eq(["JP-001"])
50
+ expect(result[:new]).to eq([])
51
+ end
52
+
53
+ it "renders a markdown table" do
54
+ markdown = described_class.to_markdown(cd, dis)
55
+
56
+ expect(markdown).to start_with("| Category | Count | Comments |")
57
+ expect(markdown).to include("| New | 1 | JP-001 |")
58
+ expect(markdown).to include("| Revised | 1 | DE-002 |")
59
+ expect(markdown).to include("Resolved (disposition recorded in the later stage): 1")
60
+ end
61
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "spec_helper"
4
+ require "zip"
4
5
  require "commenter/cli"
5
6
  require_relative "../support/osd_fixtures"
6
7
  require_relative "../support/xlsx_builder"
@@ -74,3 +75,29 @@ RSpec.describe Commenter::Cli do
74
75
  end
75
76
  end
76
77
  end
78
+
79
+ RSpec.describe Commenter::Cli do
80
+ describe "#export" do
81
+ it "retrieves observations and writes the disposition DOCX" do
82
+ Dir.mktmpdir do |dir|
83
+ config = File.join(dir, "config.yaml")
84
+ File.write(config, { "github" => { "repository" => "t/t", "token" => "dummy" } }.to_yaml)
85
+ input = File.join(dir, "comments.yaml")
86
+ File.write(input, {
87
+ "version" => "2012-03", "date" => "2026-08-29", "document" => "ISO 2533:2026",
88
+ "comments" => [{ "id" => "DE-001", "body" => "DE", "comments" => "Remark",
89
+ "observations" => "Accepted." }]
90
+ }.to_yaml)
91
+ output = File.join(dir, "disposition.docx")
92
+
93
+ described_class.start(["export", input, "--config", config, "--output", output])
94
+
95
+ expect(File).to exist(output)
96
+ header = Zip::File.open(output) do |zip|
97
+ zip.entries.find { |e| e.name == "word/header2.xml" }.get_input_stream(&:read)
98
+ end
99
+ expect(header).to include("Date: 2026-08-29")
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tempfile"
5
+
6
+ RSpec.describe Commenter::GitHubSync do
7
+ describe ".reconcile_actions" do
8
+ def actions(**overrides)
9
+ described_class.reconcile_actions(
10
+ issue: { title: "Title", body: "Body", state: "open" },
11
+ rendered: { title: "Title", body: "Body" },
12
+ disposition: nil,
13
+ **overrides
14
+ )
15
+ end
16
+
17
+ it "reports nothing when the issue is in sync" do
18
+ expect(actions).to eq([])
19
+ end
20
+
21
+ it "re-renders differing content under the yaml policy" do
22
+ expect(actions(conflict: "yaml", issue: { title: "Title", body: "Old body", state: "open" })).to eq([:update_content])
23
+ end
24
+
25
+ it "keeps differing GitHub content under the github policy" do
26
+ expect(actions(conflict: "github", issue: { title: "Title", body: "Edited on GitHub", state: "open" }))
27
+ .to eq([:keep_github_content])
28
+ end
29
+
30
+ it "reports a conflict under the skip policy" do
31
+ expect(actions(conflict: "skip", issue: { title: "Edited on GitHub", body: "Body", state: "open" })).to eq([:conflict])
32
+ end
33
+
34
+ it "closes an open issue whose comment has a disposition" do
35
+ expect(actions(disposition: "Accepted.")).to eq([:close])
36
+ end
37
+
38
+ it "combines content update and close" do
39
+ expect(actions(issue: { title: "Title", body: "Old", state: "open" }, disposition: "Accepted.")).to eq(%i[update_content close])
40
+ end
41
+
42
+ it "does not close a closed issue, an undispositioned comment, or when disabled" do
43
+ expect(actions(disposition: "Accepted.", issue: { title: "Title", body: "Body", state: "closed" })).to eq([])
44
+ expect(actions(disposition: " ")).to eq([])
45
+ expect(actions(disposition: "Accepted.", close_on_disposition: false)).to eq([])
46
+ end
47
+
48
+ it "ignores whitespace when comparing content" do
49
+ expect(actions(issue: { title: "Title", body: " Body ", state: "open" })).to eq([])
50
+ end
51
+ end
52
+
53
+ describe "#sync dry run" do
54
+ let(:config_file) do
55
+ file = Tempfile.new(["config", ".yaml"])
56
+ file.write({ "github" => { "repository" => "test-org/test-repo", "token" => "test-token" } }.to_yaml)
57
+ file.close
58
+ file
59
+ end
60
+
61
+ def yaml_file(comments)
62
+ file = Tempfile.new(["comments", ".yaml"])
63
+ file.write({ "version" => "2012-03", "stage" => "DIS", "comments" => comments }.to_yaml)
64
+ file.close
65
+ file
66
+ end
67
+
68
+ after { config_file.unlink }
69
+
70
+ it "plans creation for comments without issues and closure for dispositioned open ones" do
71
+ input = yaml_file([
72
+ { "id" => "US-001", "body" => "US", "comments" => "No issue yet" },
73
+ { "id" => "US-002", "body" => "US", "comments" => "Resolved", "observations" => "Accepted.",
74
+ "github" => { "issue_number" => 12, "status" => "open" } }
75
+ ])
76
+
77
+ sync = described_class.new(config_file.path)
78
+ results = sync.sync(input.path, dry_run: true)
79
+
80
+ expect(results[0]).to eq(comment_id: "US-001", actions: [:create])
81
+ expect(results[1][:issue_number]).to eq(12)
82
+ expect(results[1][:actions]).to include(:close)
83
+
84
+ input.unlink
85
+ end
86
+ end
87
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commenter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
@@ -152,6 +152,7 @@ files:
152
152
  - exe/commenter
153
153
  - lib/commenter.rb
154
154
  - lib/commenter/ballot.rb
155
+ - lib/commenter/ballot_diff.rb
155
156
  - lib/commenter/ballot_report.rb
156
157
  - lib/commenter/cli.rb
157
158
  - lib/commenter/comment.rb
@@ -163,6 +164,7 @@ files:
163
164
  - lib/commenter/github_info.rb
164
165
  - lib/commenter/github_integration.rb
165
166
  - lib/commenter/github_session.rb
167
+ - lib/commenter/github_sync.rb
166
168
  - lib/commenter/parser.rb
167
169
  - lib/commenter/parser/osd_xlsx_parser.rb
168
170
  - lib/commenter/parser/track_change_docx_parser.rb
@@ -170,6 +172,7 @@ files:
170
172
  - schema/iso_comment_2012-03.yaml
171
173
  - schema/iso_comment_osd.yaml
172
174
  - sig/commenter.rbs
175
+ - spec/commenter/ballot_diff_spec.rb
173
176
  - spec/commenter/ballot_report_spec.rb
174
177
  - spec/commenter/ballot_spec.rb
175
178
  - spec/commenter/cli_spec.rb
@@ -179,6 +182,7 @@ files:
179
182
  - spec/commenter/disposition_status_spec.rb
180
183
  - spec/commenter/filler_spec.rb
181
184
  - spec/commenter/github_integration_spec.rb
185
+ - spec/commenter/github_sync_spec.rb
182
186
  - spec/commenter/osd_xlsx_parser_spec.rb
183
187
  - spec/commenter/track_change_docx_parser_spec.rb
184
188
  - spec/commenter_spec.rb