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 +4 -4
- data/.rubocop.yml +4 -0
- data/CONTEXT.md +33 -0
- data/README.adoc +100 -11
- data/commenter.gemspec +1 -0
- data/lib/commenter/ballot.rb +67 -0
- data/lib/commenter/ballot_diff.rb +64 -0
- data/lib/commenter/ballot_report.rb +60 -0
- data/lib/commenter/cli.rb +139 -31
- data/lib/commenter/comment.rb +100 -115
- data/lib/commenter/comment_locality.rb +18 -0
- data/lib/commenter/comment_sheet.rb +28 -68
- data/lib/commenter/comment_type.rb +5 -1
- data/lib/commenter/disposition_status.rb +37 -0
- data/lib/commenter/filler.rb +91 -59
- data/lib/commenter/github_info.rb +23 -0
- data/lib/commenter/github_integration.rb +14 -14
- data/lib/commenter/github_sync.rb +105 -0
- data/lib/commenter/parser/osd_xlsx_parser.rb +1 -3
- data/lib/commenter/parser/track_change_docx_parser.rb +3 -5
- data/lib/commenter/parser.rb +4 -5
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +13 -5
- data/spec/commenter/ballot_diff_spec.rb +61 -0
- data/spec/commenter/ballot_report_spec.rb +62 -0
- data/spec/commenter/ballot_spec.rb +68 -0
- data/spec/commenter/cli_spec.rb +41 -0
- data/spec/commenter/comment_sheet_spec.rb +10 -10
- data/spec/commenter/comment_spec.rb +22 -9
- data/spec/commenter/disposition_status_spec.rb +40 -0
- data/spec/commenter/filler_spec.rb +119 -0
- data/spec/commenter/github_sync_spec.rb +87 -0
- data/spec/commenter/osd_xlsx_parser_spec.rb +3 -2
- data/spec/support/redline_docx_builder.rb +11 -4
- data/spec/support/xlsx_builder.rb +17 -12
- metadata +30 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 077fd6e045907b803d3f6b33d305bab0a4544b37ede3855fb26af1974f48385a
|
|
4
|
+
data.tar.gz: ffe50dadee86e040ce1b0b5fbb367f5e5233f4bd668248e9f473c2f9bfbccfb3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3a1e36e43d5dc1d16717afab89546c3c8151c38c56c8b2f8cdbf5885a0cb5d38a34f6a6a1869892bc07a1ff0efe4f4d229a4137241565d74d4e4920555eba4b
|
|
7
|
+
data.tar.gz: d368ce137f669d56ec8c72da5c3a106c7acc95e19c8daa0c3371e09cc91c5af79e6d0af0cfd4498ac466aef96c26377c5020647b8132a59fce453574a303217a
|
data/.rubocop.yml
CHANGED
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)
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
132
|
-
|
|
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)
|
|
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
|
-
|
|
32
|
-
comment_sheet = parser.parse(input_file, options)
|
|
25
|
+
comment_sheet = Parser.new.parse(input_file, options)
|
|
33
26
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|