commenter 0.3.1 → 0.5.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: 1609bb9eed80e408daebc9c67cda2c657e8484186acce89b823e6b411a5f9ea5
4
- data.tar.gz: 46316674e2c639b9256390385d2cb3c59ed8946b604b44fbb0be28728475cd38
3
+ metadata.gz: 077fd6e045907b803d3f6b33d305bab0a4544b37ede3855fb26af1974f48385a
4
+ data.tar.gz: ffe50dadee86e040ce1b0b5fbb367f5e5233f4bd668248e9f473c2f9bfbccfb3
5
5
  SHA512:
6
- metadata.gz: 887aa19ab8ca3559fd20caf3e19a9941b631c677faeda86553597671883fc8dbae8c1e1f6d83bd0014e036774743addc16bc538b7404f6939be6636e1d623704
7
- data.tar.gz: 3b850d0872f2f572b599d0bf7a70a977e1e5e7e797f5fb3447905c09cc7578cff18d99de6b32ae3b539758b7319b07590fdfd9d5249f74024ffdcd842d48d09d
6
+ metadata.gz: f3a1e36e43d5dc1d16717afab89546c3c8151c38c56c8b2f8cdbf5885a0cb5d38a34f6a6a1869892bc07a1ff0efe4f4d229a4137241565d74d4e4920555eba4b
7
+ data.tar.gz: d368ce137f669d56ec8c72da5c3a106c7acc95e19c8daa0c3371e09cc91c5af79e6d0af0cfd4498ac466aef96c26377c5020647b8132a59fce453574a303217a
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 ADDED
@@ -0,0 +1,33 @@
1
+ # CONTEXT.md — Domain Glossary
2
+
3
+ The domain language of the commenter gem. Used to name modules and commands;
4
+ keep this current when concepts sharpen.
5
+
6
+ - **Comment Sheet** — one ballot submission as a whole: metadata (document,
7
+ stage, date, project, titles) plus its comments. `CommentSheet`.
8
+ - **Comment** — a single remark on a document: identity (id, member body),
9
+ locality (clause, element, line), type, the remark text, the proposed
10
+ change, and the disposition (`observations`).
11
+ - **Member Body** — the national body or committee that submitted the comment
12
+ (`DE`, `US`, `CS` for the ISO/CS secretariat, `**` for ISO itself). A
13
+ secretariat receives one sheet per member body per ballot.
14
+ - **Ballot** — the set of all comment sheets for one ballot round. Merging
15
+ member sheets into one is collation; `Ballot`.
16
+ - **Disposition** — the resolution of a comment: accepted, accept with
17
+ modifications, noted, rejected, or still open. `DispositionStatus` is the
18
+ single matcher for both free-text observations and OSD's structured
19
+ `resolution_status`.
20
+ - **Stage** — the ballot stage of the document (WD, CD, DIS, FDIS, PRF, PUB).
21
+ The same comment ID at different stages is a different comment.
22
+ - **Redline** — a DOCX with tracked changes (`w:ins`/`w:del`/moves), typically
23
+ from ISO/CS editors; also carries reviewer remark threads.
24
+ - **OSD** — ISO Online Standards Development; its XLSX comment exports come
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`.
32
+ - **Unique ID** — stage-aware identity of a comment's GitHub issue
33
+ (`[DIS] GB-001` by default), used for duplicate detection.
data/README.adoc CHANGED
@@ -124,12 +124,16 @@ Options:
124
124
  ==== Metadata (extraction limitation)
125
125
 
126
126
  WARNING: Due to a limitation in the underlying `docx` gem, metadata fields
127
- (Date, Document, Project) from the DOCX header cannot be automatically
128
- extracted. This is because the required functionality is not yet merged (see
129
- https://github.com/ruby-docx/docx/pull/73[docx gem PR #73]).
127
+ (Date, Document, Project) cannot yet be automatically extracted from the DOCX
128
+ header during import (see https://github.com/ruby-docx/docx/pull/73[docx gem
129
+ PR #73]). Redline imports take them from the `--document` / `--stage` options;
130
+ for template imports, populate the YAML fields manually.
130
131
 
131
- The generated YAML will have empty metadata fields that you can manually
132
- populate:
132
+ The reverse direction works: `commenter fill` writes the sheet's
133
+ `date`, `document`, and `project` values into the Date/Document/Project labels
134
+ of the template's page header, so the filled comment sheet is complete.
135
+
136
+ For template imports, populate the YAML metadata manually:
133
137
 
134
138
  [source,yaml]
135
139
  ----
@@ -142,9 +146,6 @@ project: "" # Manually add: e.g., "Project name"
142
146
  comments:
143
147
  ----
144
148
 
145
- Alternatively, you can provide metadata during import using CLI options (planned
146
- for future release).
147
-
148
149
 
149
150
  ==== Example YAML output
150
151
 
@@ -189,11 +190,70 @@ comments:
189
190
  observations:
190
191
  ----
191
192
 
193
+ === Merging comment sheets (ballot collation)
194
+
195
+ A secretariat receives one comment file per member body per ballot — mixed
196
+ DOCX templates, OSD XLSX exports, and late redlines that arrive by email.
197
+ `commenter merge` collates them into a single ballot sheet:
198
+
199
+ [source,shell]
200
+ ----
201
+ commenter merge de.yaml us.yaml jp.yaml cs-redline.yaml -o ballot.yaml
202
+ ----
203
+
204
+ Metadata (document, stage, date) is taken from the first sheet that provides
205
+ each field. Comments keep their input order. Identical duplicates of the same
206
+ comment ID are deduplicated silently; the same ID with differing content
207
+ raises an error listing the conflicting IDs — collation stops loudly rather
208
+ than silently dropping a member's comment.
209
+
210
+ === Ballot statistics (disposition reports)
211
+
212
+ Every ballot report needs disposition counts — by member body and overall.
213
+ The data is already in the YAML, so `commenter stats` produces the table:
214
+
215
+ [source,shell]
216
+ ----
217
+ commenter stats ballot.yaml
218
+ ----
219
+
220
+ [source,markdown]
221
+ ....
222
+ | Body | Total | Accepted | AWM | Noted | Rejected | TODO | Open | Undecided |
223
+ |------|-------|----------|-----|-------|----------|------|------|-----------|
224
+ | DE | 12 | 5 | 3 | 2 | 1 | 0 | 0 | 1 |
225
+ | US | 9 | 4 | 1 | 2 | 1 | 0 | 1 | 0 |
226
+ | **Total** | **21** | **9** | **4** | **4** | **2** | **0** | **1** | **1** |
227
+
228
+ Comment types: technical 13 · general 5 · editorial 3
229
+ ....
230
+
231
+ A comment's disposition is read from its `observations` text, falling back to
232
+ the OSD `resolution_status` field, then to the GitHub issue state (a still-open
233
+ issue counts as *open*; no disposition at all counts as *undecided*). Use
234
+ `--format yaml` for machine-readable output, and `-o report.md` to write to a
235
+ file — the Markdown pastes directly into a GitHub issue or WG minutes.
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
+
192
251
  === Filling DOCX templates from YAML
193
252
 
194
253
  This gem contains a command-line utility to fill a DOCX template with comments
195
254
  from a YAML file. It generates a filled comment sheet that can be used for
196
- review and resolution tracking.
255
+ review and resolution tracking. The sheet's `date`, `document`, and `project`
256
+ metadata are written into the template's page header.
197
257
 
198
258
  The base template is the ISO comment sheet template located at
199
259
  `data/iso_comment_template_2012-03.docx`. You can also provide a custom
@@ -418,6 +478,35 @@ The `unique_id` is available as a variable in title and body templates:
418
478
 
419
479
  This renders to: `[DIS] GB-001: Clause 5.1 summary (ISO/DIS 2533)`
420
480
 
481
+ ==== Synchronizing with GitHub issues
482
+
483
+ `github-create` is one-shot: it creates missing issues and skips existing
484
+ ones. `github-sync` keeps the YAML and the issues in step, treating the YAML
485
+ as the source of truth:
486
+
487
+ [source,shell]
488
+ ----
489
+ commenter github-sync --config github_config.yaml comments.yaml
490
+ ----
491
+
492
+ For each comment it creates a missing issue, refreshes issue content from
493
+ the YAML, and reconciles issue state. Two policies govern the parts that
494
+ can collide:
495
+
496
+ `--conflict yaml` (default):: Re-render the issue title and body from the
497
+ YAML when they differ (YAML wins).
498
+ `--conflict github`:: Leave differing issue content untouched (edits made
499
+ on GitHub win).
500
+ `--conflict skip`:: Take no action on differing content; the result is
501
+ reported as a conflict.
502
+
503
+ By default an open issue whose comment has a recorded disposition
504
+ (`observations`) is closed — that is what keeps the
505
+ `github-retrieve` round-trip working, since observations are retrieved from
506
+ closed issues. Disable with `--no-close-on-disposition`.
507
+
508
+ The milestone is resolved once per run and reused for every issue.
509
+
421
510
  ==== Retrieving observations from GitHub issues
422
511
 
423
512
  ===== Basic usage
@@ -605,10 +694,10 @@ recognized and applied to the observations column:
605
694
  |===
606
695
  | Status Pattern | Intended Color | Hex Code | Example
607
696
 
697
+ | `awm\|accept(ed)? with (modifications\|changes)` | Olive Green | #C4D79B | "Accepted with modifications"
608
698
  | `accept(ed)?` | Green | #92D050 | "Accepted"
609
- | `awm\|accept with modifications` | Olive Green | #C4D79B | "Accept with modifications"
610
699
  | `noted` | Blue | #8DB4E2 | "Noted"
611
- | `reject(ed)?` | Pink | #FF99CC | "Rejected"
700
+ | `reject(ed)?\|not accepted` | Pink | #FF99CC | "Rejected"
612
701
  | `todo` | Diagonal stripes | #D9D9D9 | "TODO: Review"
613
702
 
614
703
  |===
data/commenter.gemspec CHANGED
@@ -34,6 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency "docx", "~> 0.8"
35
35
  spec.add_dependency "dotenv", "~> 2.8"
36
36
  spec.add_dependency "liquid", "~> 5.0"
37
+ spec.add_dependency "lutaml-model", "~> 0.8"
37
38
  spec.add_dependency "octokit", "~> 6.0"
38
39
  spec.add_dependency "roo", ">= 2.10"
39
40
  spec.add_dependency "thor", "~> 1.0"
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commenter
4
+ # Merges member-body comment sheets into a single ballot sheet — the
5
+ # collation step secretariats otherwise do by hand (ISO's own Comment
6
+ # Collation Tool fails on non-conforming files).
7
+ #
8
+ # Policy: sheet metadata is first-non-nil across the given sheets (the
9
+ # output version comes from the first sheet); comments keep their input
10
+ # order. Identical duplicate IDs are deduplicated silently; the same ID
11
+ # with differing content raises.
12
+ class Ballot
13
+ def initialize(sheets)
14
+ @sheets = sheets
15
+ end
16
+
17
+ def merge
18
+ CommentSheet.new(
19
+ version: first_sheet&.version || "2012-03",
20
+ date: first_value(:date),
21
+ document: first_value(:document),
22
+ project: first_value(:project),
23
+ stage: first_value(:stage),
24
+ title_en: first_value(:title_en),
25
+ title_fr: first_value(:title_fr),
26
+ comments: deduplicate(@sheets.flat_map(&:comments))
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def first_sheet
33
+ @sheets.first
34
+ end
35
+
36
+ def first_value(attribute)
37
+ @sheets.lazy.map { |sheet| sheet.public_send(attribute) }.find { |value| !value.nil? }
38
+ end
39
+
40
+ def deduplicate(comments)
41
+ seen = {}
42
+ conflicts = []
43
+ kept = comments.reject do |comment|
44
+ key = comment.id.to_s
45
+ next false if key.empty?
46
+
47
+ existing = seen[key]
48
+ if existing.nil?
49
+ seen[key] = comment
50
+ false
51
+ elsif identical?(existing, comment)
52
+ true
53
+ else
54
+ conflicts << key
55
+ false
56
+ end
57
+ end
58
+ return kept if conflicts.empty?
59
+
60
+ raise Commenter::Error, "Duplicate comment IDs with differing content: #{conflicts.uniq.sort.join(", ")}"
61
+ end
62
+
63
+ def identical?(one, another)
64
+ one.comments == another.comments && one.proposed_change == another.proposed_change
65
+ end
66
+ end
67
+ end
@@ -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
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Commenter
4
+ # Disposition statistics over a comment sheet: the counts ballot reports
5
+ # need — comments per member body by disposition, and per comment type.
6
+ module BallotReport
7
+ module_function
8
+
9
+ # status bucket for one comment: matched observation text, else matched
10
+ # OSD resolution_status, else open when a GitHub issue is still open,
11
+ # else undecided.
12
+ def status_for(comment)
13
+ DispositionStatus.match(comment.observations) ||
14
+ DispositionStatus.match(comment.resolution_status) ||
15
+ (comment.github&.status == "open" ? :open : nil) ||
16
+ (comment.observations.to_s.strip.empty? && comment.resolution_status.to_s.strip.empty? ? :undecided : nil)
17
+ end
18
+
19
+ def counts(comment_sheet)
20
+ bodies = Hash.new { |hash, body| hash[body] = bucket_hash }
21
+ types = Hash.new(0)
22
+ comment_sheet.comments.each do |comment|
23
+ body = comment.body.to_s.strip.empty? ? "(unknown)" : comment.body
24
+ bodies[body][:total] += 1
25
+ status = status_for(comment)
26
+ bodies[body][status] += 1 if status
27
+ types[comment.type.to_s.strip.empty? ? "(untyped)" : comment.type] += 1
28
+ end
29
+ { total: bodies.values.sum { |bucket| bucket[:total] }, bodies: bodies, types: types }
30
+ end
31
+
32
+ def to_markdown(comment_sheet)
33
+ data = counts(comment_sheet)
34
+ sorted_bodies = data[:bodies].sort_by { |body, _| body }
35
+
36
+ lines = []
37
+ lines << "| Body | Total | Accepted | AWM | Noted | Rejected | TODO | Open | Undecided |"
38
+ lines << "|------|-------|----------|-----|-------|----------|------|------|-----------|"
39
+ sorted_bodies.each do |body, bucket|
40
+ lines << "| #{body} | #{bucket[:total]} | #{bucket[:accepted]} | #{bucket[:accept_with_modifications]} | " \
41
+ "#{bucket[:noted]} | #{bucket[:rejected]} | #{bucket[:todo]} | #{bucket[:open]} | #{bucket[:undecided]} |"
42
+ end
43
+ totals = data[:bodies].values.reduce(bucket_hash) { |sum, bucket| merge_bucket(sum, bucket) }
44
+ lines << "| **Total** | **#{totals[:total]}** | **#{totals[:accepted]}** | " \
45
+ "**#{totals[:accept_with_modifications]}** | **#{totals[:noted]}** | " \
46
+ "**#{totals[:rejected]}** | **#{totals[:todo]}** | **#{totals[:open]}** | **#{totals[:undecided]}** |"
47
+ lines << ""
48
+ lines << "Comment types: #{data[:types].sort_by { |type, _| type }.map { |type, count| "#{type} #{count}" }.join(" · ")}"
49
+ lines.join("\n")
50
+ end
51
+
52
+ def bucket_hash
53
+ { total: 0, accepted: 0, accept_with_modifications: 0, noted: 0, rejected: 0, todo: 0, open: 0, undecided: 0 }
54
+ end
55
+
56
+ def merge_bucket(sum, bucket)
57
+ sum.merge(bucket) { |_key, accumulated, addition| accumulated + addition }
58
+ end
59
+ end
60
+ end
data/lib/commenter/cli.rb CHANGED
@@ -21,33 +21,71 @@ module Commenter
21
21
  option :document, type: :string, desc: "Redline: document identifier (e.g. ISO 2533:2026)"
22
22
  option :stage, type: :string, desc: "Redline: approval stage (e.g. DIS)"
23
23
  def import(input_file)
24
- output_yaml = options[:output]
25
- schema_dir = options[:schema_dir]
26
-
27
- # Ensure schema directory exists
28
- FileUtils.mkdir_p(schema_dir) unless Dir.exist?(schema_dir)
29
-
30
24
  # Parse the input file
31
- parser = Parser.new
32
- comment_sheet = parser.parse(input_file, options)
25
+ comment_sheet = Parser.new.parse(input_file, options)
33
26
 
34
- # Write the YAML data file with schema reference
35
- File.write(output_yaml, comment_sheet.to_yaml_document(schema_dir))
27
+ schema_target = write_sheet(comment_sheet, options[:output], options[:schema_dir])
36
28
 
37
- # Copy schema file to output directory
38
- schema_name = comment_sheet.schema_name
39
- schema_source = File.join(__dir__, "../../schema/#{schema_name}")
40
- schema_target = File.join(schema_dir, schema_name)
41
-
42
- # Only copy if source and target are different
43
- FileUtils.cp(schema_source, schema_target) unless File.expand_path(schema_source) == File.expand_path(schema_target)
44
-
45
- puts "Converted #{input_file} to #{output_yaml}"
29
+ puts "Converted #{input_file} to #{options[:output]}"
46
30
  puts " Version: #{comment_sheet.version}"
47
31
  puts " Comments: #{comment_sheet.comments.length}"
48
32
  puts " Schema: #{schema_target}"
49
33
  end
50
34
 
35
+ desc "merge INPUT.yaml...", "Merge multiple comment sheets into one ballot sheet"
36
+ option :output, type: :string, aliases: :o, default: "merged.yaml", desc: "Output YAML file"
37
+ option :schema_dir, type: :string, default: "schema", desc: "Directory for schema file"
38
+ def merge(*input_files)
39
+ raise "At least one input file is required" if input_files.empty?
40
+
41
+ sheets = input_files.map { |path| CommentSheet.from_yaml(File.read(path)) }
42
+ comment_sheet = Ballot.new(sheets).merge
43
+ write_sheet(comment_sheet, options[:output], options[:schema_dir])
44
+
45
+ puts "Merged #{input_files.length} files (#{comment_sheet.comments.length} comments) to #{options[:output]}"
46
+ end
47
+
48
+ desc "stats INPUT.yaml", "Disposition statistics for ballot reports"
49
+ option :output, type: :string, aliases: :o, desc: "Output file (default: stdout)"
50
+ option :format, type: :string, default: "markdown", enum: %w[markdown yaml], desc: "Report format"
51
+ def stats(input_yaml)
52
+ comment_sheet = CommentSheet.from_yaml(File.read(input_yaml))
53
+
54
+ report = if options[:format] == "yaml"
55
+ BallotReport.counts(comment_sheet).to_yaml
56
+ else
57
+ BallotReport.to_markdown(comment_sheet)
58
+ end
59
+
60
+ if options[:output]
61
+ File.write(options[:output], report)
62
+ puts "Wrote statistics to #{options[:output]}"
63
+ else
64
+ puts report
65
+ end
66
+ end
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
+
51
89
  desc "fill INPUT.yaml", "Fill DOCX template from YAML comments"
52
90
  option :output, type: :string, aliases: :o, default: "filled_comments.docx", desc: "Output DOCX file"
53
91
  option :template, type: :string, aliases: :t, desc: "Custom template file"
@@ -55,23 +93,20 @@ module Commenter
55
93
  def fill(input_yaml)
56
94
  output_docx = options[:output]
57
95
 
58
- # Load YAML data
59
- data = YAML.load_file(input_yaml)
60
-
61
- # Extract comments from the structure
62
- comments = if data.is_a?(Hash)
63
- data["comments"] || data[:comments] || []
64
- else
65
- data || []
66
- end
67
-
96
+ comment_sheet = CommentSheet.from_yaml(File.read(input_yaml))
97
+ comments = comment_sheet.comments
68
98
  raise "No comments found in YAML file" if comments.empty?
69
99
 
70
100
  # Use default template if none specified
71
101
  template_path = options[:template] || File.join(__dir__, "../../data/iso_comment_template_2012-03.docx")
72
102
 
73
- # Fill the template
74
- Filler.new.fill(template_path, output_docx, comments, options)
103
+ # Fill the template, including the sheet metadata in the page header
104
+ fill_options = options.merge(
105
+ date: comment_sheet.date,
106
+ document: comment_sheet.document,
107
+ project: comment_sheet.project
108
+ ).compact
109
+ Filler.new.fill(template_path, output_docx, comments, fill_options)
75
110
  puts "Filled template to #{output_docx}"
76
111
  end
77
112
 
@@ -146,6 +181,66 @@ module Commenter
146
181
  exit 1
147
182
  end
148
183
 
184
+ desc "github-sync INPUT.yaml", "Synchronize comments with GitHub issues (YAML is the source of truth)"
185
+ option :config, type: :string, aliases: :c, required: true, desc: "GitHub configuration YAML file"
186
+ option :output, type: :string, aliases: :o, desc: "Output YAML file (default: update original)"
187
+ option :stage, type: :string, desc: "Override approval stage (WD/CD/DIS/FDIS/PRF/PUB)"
188
+ option :milestone, type: :string, desc: "Override milestone name or number"
189
+ option :assignee, type: :string, desc: "Override assignee GitHub handle"
190
+ option :title_template, type: :string, desc: "Custom title template"
191
+ option :body_template, type: :string, desc: "Custom body template"
192
+ option :dry_run, type: :boolean, desc: "Preview sync actions without applying them"
193
+ option :conflict, type: :string, default: "yaml", enum: %w[yaml github skip],
194
+ desc: "Conflict policy: yaml re-renders the issue, " \
195
+ "github keeps the issue content, skip reports and leaves it"
196
+ option :close_on_disposition, type: :boolean, default: true,
197
+ desc: "Close open issues whose comment has a recorded " \
198
+ "disposition"
199
+ def github_sync(input_yaml)
200
+ sync = GitHubSync.new(options[:config], options[:title_template], options[:body_template])
201
+
202
+ sync_options = {
203
+ stage: options[:stage],
204
+ milestone: options[:milestone],
205
+ assignee: options[:assignee],
206
+ conflict: options[:conflict],
207
+ close_on_disposition: options[:close_on_disposition],
208
+ dry_run: options[:dry_run],
209
+ output: options[:output]
210
+ }.compact
211
+ results = sync.sync(input_yaml, sync_options)
212
+
213
+ if options[:dry_run]
214
+ puts "DRY RUN - Planned sync actions:"
215
+ puts "=" * 50
216
+ results.each do |result|
217
+ issue = result[:issue_number] ? "issue ##{result[:issue_number]}" : "no issue yet"
218
+ puts "#{result[:comment_id]} (#{issue}): #{result[:actions].join(", ")}"
219
+ end
220
+ puts "-" * 30
221
+ puts "Based on the issue state last recorded in the YAML; run without --dry-run for a live pass."
222
+ else
223
+ puts "GitHub sync results:"
224
+ puts "=" * 40
225
+ results.each do |result|
226
+ case result[:status]
227
+ when :created then puts "\u2713 #{result[:comment_id]}: Created issue ##{result[:issue_number]} " \
228
+ "(#{result[:issue_url]})"
229
+ when :updated then puts "\u21bb #{result[:comment_id]}: Updated issue ##{result[:issue_number]} " \
230
+ "(#{result[:actions].join(", ")})"
231
+ when :closed then puts "\u2713 #{result[:comment_id]}: Closed issue ##{result[:issue_number]} (disposition recorded)"
232
+ when :updated_and_closed then puts "\u21bb\u2713 #{result[:comment_id]}: Updated and closed issue ##{result[:issue_number]}"
233
+ when :unchanged then puts "- #{result[:comment_id]}: Issue ##{result[:issue_number]} in sync"
234
+ when :error then puts "\u2717 #{result[:comment_id]}: Error - #{result[:message]}"
235
+ end
236
+ end
237
+ puts "Updated YAML file: #{options[:output] || input_yaml}"
238
+ end
239
+ rescue StandardError => e
240
+ puts "Error: #{e.message}"
241
+ exit 1
242
+ end
243
+
149
244
  desc "github-retrieve INPUT.yaml", "Retrieve observations from GitHub issues"
150
245
  option :config, type: :string, aliases: :c, required: true, desc: "GitHub configuration YAML file"
151
246
  option :output, type: :string, aliases: :o, desc: "Output YAML file (default: update original)"
@@ -212,5 +307,18 @@ module Commenter
212
307
  def self.exit_on_failure?
213
308
  true
214
309
  end
310
+
311
+ private
312
+
313
+ def write_sheet(comment_sheet, output_yaml, schema_dir)
314
+ FileUtils.mkdir_p(schema_dir) unless Dir.exist?(schema_dir)
315
+ File.write(output_yaml, comment_sheet.to_yaml_document(schema_dir))
316
+
317
+ schema_name = comment_sheet.schema_name
318
+ schema_source = File.join(__dir__, "../../schema/#{schema_name}")
319
+ schema_target = File.join(schema_dir, schema_name)
320
+ FileUtils.cp(schema_source, schema_target) unless File.expand_path(schema_source) == File.expand_path(schema_target)
321
+ schema_target
322
+ end
215
323
  end
216
324
  end