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
|
@@ -19,8 +19,7 @@ module Commenter
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def create_issues_from_yaml(yaml_file, options = {})
|
|
22
|
-
|
|
23
|
-
comment_sheet = CommentSheet.from_hash(data)
|
|
22
|
+
comment_sheet = CommentSheet.from_yaml(File.read(yaml_file))
|
|
24
23
|
|
|
25
24
|
# Override stage if provided
|
|
26
25
|
comment_sheet.stage = options[:stage] if options[:stage]
|
|
@@ -251,8 +250,12 @@ module Commenter
|
|
|
251
250
|
puts "[GitHubIssueCreator] Using milestone number: #{milestone_config["number"]}"
|
|
252
251
|
milestone_config["number"]
|
|
253
252
|
elsif milestone_config["name"]
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
256
259
|
end
|
|
257
260
|
end
|
|
258
261
|
|
|
@@ -277,16 +280,14 @@ module Commenter
|
|
|
277
280
|
def update_yaml_with_github_info(yaml_file, comment_sheet, results, options)
|
|
278
281
|
# Update comments with GitHub information
|
|
279
282
|
results.each do |result|
|
|
280
|
-
next unless result[:
|
|
283
|
+
next unless result[:issue_number]
|
|
281
284
|
|
|
282
285
|
comment = comment_sheet.comments.find { |c| c.id == result[:comment_id] }
|
|
283
286
|
next unless comment
|
|
284
287
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
comment.github[:status] = "open"
|
|
289
|
-
comment.github[:created_at] = Time.now.utc.iso8601
|
|
288
|
+
comment.record_github_issue(issue_number: result[:issue_number], issue_url: result[:issue_url],
|
|
289
|
+
status: result[:status] == :created ? "open" : comment.github_status,
|
|
290
|
+
created_at: comment.github_created_at || Time.now.utc.iso8601)
|
|
290
291
|
end
|
|
291
292
|
|
|
292
293
|
# Write updated YAML
|
|
@@ -304,8 +305,7 @@ module Commenter
|
|
|
304
305
|
end
|
|
305
306
|
|
|
306
307
|
def retrieve_observations_from_yaml(yaml_file, options = {})
|
|
307
|
-
|
|
308
|
-
comment_sheet = CommentSheet.from_hash(data)
|
|
308
|
+
comment_sheet = CommentSheet.from_yaml(File.read(yaml_file))
|
|
309
309
|
|
|
310
310
|
results = []
|
|
311
311
|
comment_sheet.comments.each do |comment|
|
|
@@ -349,8 +349,8 @@ module Commenter
|
|
|
349
349
|
if observation
|
|
350
350
|
# Update comment with observation and current status
|
|
351
351
|
comment.observations = observation
|
|
352
|
-
comment.github
|
|
353
|
-
comment.github
|
|
352
|
+
comment.github.status = issue.state
|
|
353
|
+
comment.github.updated_at = Time.now.utc.iso8601
|
|
354
354
|
|
|
355
355
|
{
|
|
356
356
|
comment_id: comment.id,
|
|
@@ -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,8 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "roo"
|
|
4
|
-
require_relative "../comment_sheet"
|
|
5
|
-
require_relative "../comment"
|
|
6
4
|
|
|
7
5
|
module Commenter
|
|
8
6
|
class Parser
|
|
@@ -201,7 +199,7 @@ module Commenter
|
|
|
201
199
|
clause: clause.empty? ? nil : clause,
|
|
202
200
|
element: clause_title.empty? ? nil : clause_title
|
|
203
201
|
}.compact,
|
|
204
|
-
type: CommentType.
|
|
202
|
+
type: CommentType.full_name(cell_value_str(row, col_map[:comment_type])),
|
|
205
203
|
comments: comment_text.to_s.strip,
|
|
206
204
|
proposed_change: cell_value_str(row, col_map[:proposed_change]) ||
|
|
207
205
|
cell_value_str(row, col_map[:proposal_on_text])
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "zip"
|
|
4
4
|
require "nokogiri"
|
|
5
|
-
require_relative "../comment"
|
|
6
|
-
require_relative "../comment_sheet"
|
|
7
5
|
|
|
8
6
|
module Commenter
|
|
9
7
|
class Parser
|
|
@@ -54,7 +52,7 @@ module Commenter
|
|
|
54
52
|
raise Commenter::Error, "word/document.xml not found in #{path}" unless entry
|
|
55
53
|
|
|
56
54
|
remarks = read_remarks(zip)
|
|
57
|
-
changes =
|
|
55
|
+
changes = entry.get_input_stream { |stream| stream_changes(stream, anchors) }
|
|
58
56
|
end
|
|
59
57
|
|
|
60
58
|
CommentSheet.new(
|
|
@@ -80,7 +78,7 @@ module Commenter
|
|
|
80
78
|
entry = zip.glob("word/comments.xml").first
|
|
81
79
|
return {} unless entry
|
|
82
80
|
|
|
83
|
-
document = Nokogiri::XML(
|
|
81
|
+
document = entry.get_input_stream { |stream| Nokogiri::XML(stream) }
|
|
84
82
|
document.xpath("//w:comment", "w" => W_NS).each_with_object({}) do |node, remarks|
|
|
85
83
|
remarks[node["w:id"]] = {
|
|
86
84
|
author: node["w:author"],
|
|
@@ -102,7 +100,7 @@ module Commenter
|
|
|
102
100
|
id: format("%<body>s-%03<index>d", body: body, index: index + 1),
|
|
103
101
|
body: body,
|
|
104
102
|
locality: { clause: change[:clause], line_number: nil, element: change[:element] },
|
|
105
|
-
type: options[:type] || "te",
|
|
103
|
+
type: CommentType.full_name(options[:type] || "te"),
|
|
106
104
|
comments: "Track change (#{KIND_COMMENT.fetch(kind, kind)})",
|
|
107
105
|
proposed_change: "#{KIND_LABEL.fetch(kind, kind)}: \"#{change[:text].strip}\"",
|
|
108
106
|
observations: observations_for(options)
|
data/lib/commenter/parser.rb
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "docx"
|
|
4
4
|
require "pathname"
|
|
5
|
-
require_relative "comment_sheet"
|
|
6
|
-
require_relative "comment"
|
|
7
|
-
require_relative "parser/osd_xlsx_parser"
|
|
8
|
-
require_relative "parser/track_change_docx_parser"
|
|
9
5
|
|
|
10
6
|
module Commenter
|
|
11
7
|
class Parser
|
|
8
|
+
autoload :OsdXlsxParser, "commenter/parser/osd_xlsx_parser"
|
|
9
|
+
autoload :TrackChangeDocxParser, "commenter/parser/track_change_docx_parser"
|
|
10
|
+
|
|
12
11
|
def parse(input_path, options = {})
|
|
13
12
|
format = detect_format(input_path, options)
|
|
14
13
|
|
|
@@ -90,7 +89,7 @@ module Commenter
|
|
|
90
89
|
clause: presence(cells[2]),
|
|
91
90
|
element: presence(cells[3])
|
|
92
91
|
},
|
|
93
|
-
type: CommentType.
|
|
92
|
+
type: CommentType.full_name(cells[4]),
|
|
94
93
|
comments: cells[5] || "",
|
|
95
94
|
proposed_change: presence(cells[6]),
|
|
96
95
|
user_name: cells[0].to_s.strip
|
data/lib/commenter/version.rb
CHANGED
data/lib/commenter.rb
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "commenter/version"
|
|
4
|
-
require_relative "commenter/comment"
|
|
5
|
-
require_relative "commenter/comment_sheet"
|
|
6
|
-
require_relative "commenter/parser"
|
|
7
|
-
require_relative "commenter/filler"
|
|
8
|
-
require_relative "commenter/github_integration"
|
|
9
4
|
|
|
10
5
|
# Commenter converts ISO comment sheets (DOCX/XLSX) to structured YAML and
|
|
11
6
|
# syncs comments to GitHub issues.
|
|
@@ -13,5 +8,18 @@ module Commenter
|
|
|
13
8
|
class Error < StandardError; end
|
|
14
9
|
|
|
15
10
|
autoload :CommentType, "commenter/comment_type"
|
|
11
|
+
autoload :CommentLocality, "commenter/comment_locality"
|
|
12
|
+
autoload :GithubInfo, "commenter/github_info"
|
|
13
|
+
autoload :DispositionStatus, "commenter/disposition_status"
|
|
14
|
+
autoload :Ballot, "commenter/ballot"
|
|
15
|
+
autoload :BallotReport, "commenter/ballot_report"
|
|
16
|
+
autoload :BallotDiff, "commenter/ballot_diff"
|
|
17
|
+
autoload :Comment, "commenter/comment"
|
|
18
|
+
autoload :CommentSheet, "commenter/comment_sheet"
|
|
16
19
|
autoload :GitHubSession, "commenter/github_session"
|
|
20
|
+
autoload :Parser, "commenter/parser"
|
|
21
|
+
autoload :Filler, "commenter/filler"
|
|
22
|
+
autoload :GitHubIssueCreator, "commenter/github_integration"
|
|
23
|
+
autoload :GitHubIssueRetriever, "commenter/github_integration"
|
|
24
|
+
autoload :GitHubSync, "commenter/github_sync"
|
|
17
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
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Commenter::BallotReport do
|
|
6
|
+
def comment(body:, observations: nil, resolution_status: nil, github: {}, type: "technical")
|
|
7
|
+
Commenter::Comment.new(
|
|
8
|
+
id: "#{body}-1", body: body, type: type,
|
|
9
|
+
comments: "A comment", observations: observations,
|
|
10
|
+
resolution_status: resolution_status, github: github
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
let(:sheet) do
|
|
15
|
+
Commenter::CommentSheet.new(comments: [
|
|
16
|
+
comment(body: "DE", observations: "Accepted."),
|
|
17
|
+
comment(body: "DE", observations: "Accept with modifications"),
|
|
18
|
+
comment(body: "US", observations: "Noted"),
|
|
19
|
+
comment(body: "US", observations: "Rejected"),
|
|
20
|
+
comment(body: "US"),
|
|
21
|
+
comment(body: "CS", resolution_status: "Not accepted"),
|
|
22
|
+
comment(body: "CS", github: { status: "open" })
|
|
23
|
+
])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe ".status_for" do
|
|
27
|
+
it "prefers observation text, then resolution status, then GitHub state" do
|
|
28
|
+
comments = sheet.comments
|
|
29
|
+
expect(described_class.status_for(comments[0])).to eq(:accepted)
|
|
30
|
+
expect(described_class.status_for(comments[4])).to eq(:undecided)
|
|
31
|
+
expect(described_class.status_for(comments[5])).to eq(:rejected)
|
|
32
|
+
expect(described_class.status_for(comments[6])).to eq(:open)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe ".counts" do
|
|
37
|
+
it "counts per member body, disposition, and type" do
|
|
38
|
+
counts = described_class.counts(sheet)
|
|
39
|
+
|
|
40
|
+
expect(counts[:total]).to eq(7)
|
|
41
|
+
expect(counts[:bodies]["DE"][:total]).to eq(2)
|
|
42
|
+
expect(counts[:bodies]["DE"][:accepted]).to eq(1)
|
|
43
|
+
expect(counts[:bodies]["DE"][:accept_with_modifications]).to eq(1)
|
|
44
|
+
expect(counts[:bodies]["US"][:noted]).to eq(1)
|
|
45
|
+
expect(counts[:bodies]["US"][:rejected]).to eq(1)
|
|
46
|
+
expect(counts[:bodies]["US"][:undecided]).to eq(1)
|
|
47
|
+
expect(counts[:bodies]["CS"][:open]).to eq(1)
|
|
48
|
+
expect(counts[:types]["technical"]).to eq(7)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe ".to_markdown" do
|
|
53
|
+
it "renders a ballot report table with totals" do
|
|
54
|
+
markdown = described_class.to_markdown(sheet)
|
|
55
|
+
|
|
56
|
+
expect(markdown).to start_with("| Body | Total | Accepted | AWM | Noted | Rejected | TODO | Open | Undecided |")
|
|
57
|
+
expect(markdown).to include("| DE | 2 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |")
|
|
58
|
+
expect(markdown).to include("| **Total** | **7** | **1** | **1** | **1** | **2** | **0** | **1** | **1** |")
|
|
59
|
+
expect(markdown).to include("Comment types: technical 7")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Commenter::Ballot do
|
|
6
|
+
def sheet(body:, ids:, document: nil, stage: nil)
|
|
7
|
+
Commenter::CommentSheet.new(
|
|
8
|
+
version: "2012-03",
|
|
9
|
+
document: document,
|
|
10
|
+
stage: stage,
|
|
11
|
+
comments: ids.map do |id|
|
|
12
|
+
Commenter::Comment.new(id: id, body: body, comments: "Comment #{id} from #{body}")
|
|
13
|
+
end
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "combines comments from all sheets in input order" do
|
|
18
|
+
merged = described_class.new([
|
|
19
|
+
sheet(body: "DE", ids: %w[DE-001 DE-002]),
|
|
20
|
+
sheet(body: "US", ids: %w[US-001])
|
|
21
|
+
]).merge
|
|
22
|
+
|
|
23
|
+
expect(merged.comments.map(&:id)).to eq(%w[DE-001 DE-002 US-001])
|
|
24
|
+
expect(merged.comments.map(&:body)).to eq(%w[DE DE US])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "takes metadata from the first sheet that provides it" do
|
|
28
|
+
merged = described_class.new([
|
|
29
|
+
sheet(body: "DE", ids: %w[DE-001], stage: nil),
|
|
30
|
+
sheet(body: "US", ids: %w[US-001], document: "ISO 2533:2026", stage: "DIS")
|
|
31
|
+
]).merge
|
|
32
|
+
|
|
33
|
+
expect(merged.version).to eq("2012-03")
|
|
34
|
+
expect(merged.document).to eq("ISO 2533:2026")
|
|
35
|
+
expect(merged.stage).to eq("DIS")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "silently deduplicates identical comments sharing an ID" do
|
|
39
|
+
duplicate = Commenter::Comment.new(id: "DE-001", body: "DE", comments: "Same text", proposed_change: "Same change")
|
|
40
|
+
|
|
41
|
+
merged = described_class.new([
|
|
42
|
+
Commenter::CommentSheet.new(comments: [duplicate]),
|
|
43
|
+
Commenter::CommentSheet.new(comments: [
|
|
44
|
+
Commenter::Comment.new(id: "DE-001", body: "DE", comments: "Same text",
|
|
45
|
+
proposed_change: "Same change")
|
|
46
|
+
])
|
|
47
|
+
]).merge
|
|
48
|
+
|
|
49
|
+
expect(merged.comments.map(&:id)).to eq(["DE-001"])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "raises when the same ID carries differing content" do
|
|
53
|
+
conflicting = Commenter::Comment.new(id: "DE-001", body: "DE", comments: "Different text entirely")
|
|
54
|
+
|
|
55
|
+
expect do
|
|
56
|
+
described_class.new([
|
|
57
|
+
sheet(body: "DE", ids: %w[DE-001]),
|
|
58
|
+
Commenter::CommentSheet.new(comments: [conflicting])
|
|
59
|
+
]).merge
|
|
60
|
+
end.to raise_error(Commenter::Error, /DE-001/)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "returns an empty sheet when no input sheets are given" do
|
|
64
|
+
merged = described_class.new([]).merge
|
|
65
|
+
|
|
66
|
+
expect(merged.comments).to eq([])
|
|
67
|
+
end
|
|
68
|
+
end
|
data/spec/commenter/cli_spec.rb
CHANGED
|
@@ -32,4 +32,45 @@ RSpec.describe Commenter::Cli do
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
describe "#merge" do
|
|
37
|
+
it "writes a combined sheet with schema reference" do
|
|
38
|
+
Dir.mktmpdir do |dir|
|
|
39
|
+
one = File.join(dir, "de.yaml")
|
|
40
|
+
two = File.join(dir, "us.yaml")
|
|
41
|
+
File.write(one, { "version" => "2012-03", "comments" => [
|
|
42
|
+
{ "id" => "DE-001", "body" => "DE", "comments" => "First" }
|
|
43
|
+
] }.to_yaml)
|
|
44
|
+
File.write(two, { "comments" => [
|
|
45
|
+
{ "id" => "US-001", "body" => "US", "comments" => "Second" }
|
|
46
|
+
] }.to_yaml)
|
|
47
|
+
output = File.join(dir, "out", "merged.yaml")
|
|
48
|
+
schema_dir = File.join(dir, "out", "schema")
|
|
49
|
+
|
|
50
|
+
described_class.start(["merge", one, two, "--output", output, "--schema-dir", schema_dir])
|
|
51
|
+
|
|
52
|
+
data = YAML.safe_load_file(output)
|
|
53
|
+
expect(data["comments"].map { |comment| comment["id"] }).to eq(%w[DE-001 US-001])
|
|
54
|
+
expect(File).to exist(File.join(schema_dir, "iso_comment_2012-03.yaml"))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe "#stats" do
|
|
60
|
+
it "prints a markdown disposition report" do
|
|
61
|
+
Dir.mktmpdir do |dir|
|
|
62
|
+
input = File.join(dir, "comments.yaml")
|
|
63
|
+
File.write(input, { "comments" => [
|
|
64
|
+
{ "id" => "DE-001", "body" => "DE", "comments" => "A", "observations" => "Accepted." },
|
|
65
|
+
{ "id" => "US-001", "body" => "US", "comments" => "B", "observations" => "Noted" }
|
|
66
|
+
] }.to_yaml)
|
|
67
|
+
|
|
68
|
+
expect { described_class.start(["stats", input]) }
|
|
69
|
+
.to output(a_string_including(
|
|
70
|
+
"| DE | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |",
|
|
71
|
+
"| US | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |"
|
|
72
|
+
)).to_stdout
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
35
76
|
end
|
|
@@ -115,21 +115,21 @@ RSpec.describe Commenter::CommentSheet do
|
|
|
115
115
|
|
|
116
116
|
hash = sheet.to_h
|
|
117
117
|
|
|
118
|
-
expect(hash[
|
|
119
|
-
expect(hash[
|
|
120
|
-
expect(hash[
|
|
121
|
-
expect(hash[
|
|
122
|
-
expect(hash[
|
|
123
|
-
expect(hash[
|
|
124
|
-
expect(hash[
|
|
118
|
+
expect(hash["version"]).to eq("2012-03")
|
|
119
|
+
expect(hash["date"]).to eq("2024-06-04")
|
|
120
|
+
expect(hash["document"]).to eq("Test Document")
|
|
121
|
+
expect(hash["project"]).to eq("Test Project")
|
|
122
|
+
expect(hash["stage"]).to eq("DIS")
|
|
123
|
+
expect(hash["comments"]).to be_an(Array)
|
|
124
|
+
expect(hash["comments"].first).to be_a(Hash)
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
-
it "
|
|
127
|
+
it "omits unset attributes from the hash" do
|
|
128
128
|
sheet = described_class.new({})
|
|
129
129
|
hash = sheet.to_h
|
|
130
130
|
|
|
131
|
-
expect(hash).
|
|
132
|
-
expect(
|
|
131
|
+
expect(hash).not_to have_key("stage")
|
|
132
|
+
expect(sheet.version).to eq("2012-03")
|
|
133
133
|
end
|
|
134
134
|
end
|
|
135
135
|
|
|
@@ -9,7 +9,7 @@ RSpec.describe Commenter::Comment do
|
|
|
9
9
|
id: "US-001",
|
|
10
10
|
body: "US",
|
|
11
11
|
locality: { clause: "5.1", element: "Table 1", line_number: "42" },
|
|
12
|
-
type: "
|
|
12
|
+
type: "technical",
|
|
13
13
|
comments: "Test comment",
|
|
14
14
|
proposed_change: "Test change",
|
|
15
15
|
observations: "Test observations"
|
|
@@ -28,6 +28,19 @@ RSpec.describe Commenter::Comment do
|
|
|
28
28
|
expect(comment.observations).to eq("Test observations")
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
it "expands short type codes when loaded from YAML" do
|
|
32
|
+
yaml = <<~YAML
|
|
33
|
+
---
|
|
34
|
+
id: US-009
|
|
35
|
+
type: te
|
|
36
|
+
YAML
|
|
37
|
+
|
|
38
|
+
comment = described_class.from_yaml(yaml)
|
|
39
|
+
|
|
40
|
+
expect(comment.id).to eq("US-009")
|
|
41
|
+
expect(comment.type).to eq("technical")
|
|
42
|
+
end
|
|
43
|
+
|
|
31
44
|
it "handles string keys in attributes" do
|
|
32
45
|
attributes = {
|
|
33
46
|
"id" => "US-001",
|
|
@@ -162,7 +175,7 @@ RSpec.describe Commenter::Comment do
|
|
|
162
175
|
id: "US-001",
|
|
163
176
|
body: "US",
|
|
164
177
|
locality: { clause: "5.1" },
|
|
165
|
-
type: "
|
|
178
|
+
type: "technical",
|
|
166
179
|
comments: "Test",
|
|
167
180
|
proposed_change: "Change",
|
|
168
181
|
observations: "Obs"
|
|
@@ -170,13 +183,13 @@ RSpec.describe Commenter::Comment do
|
|
|
170
183
|
|
|
171
184
|
hash = comment.to_h
|
|
172
185
|
|
|
173
|
-
expect(hash[
|
|
174
|
-
expect(hash[
|
|
175
|
-
expect(hash[
|
|
176
|
-
expect(hash[
|
|
177
|
-
expect(hash[
|
|
178
|
-
expect(hash[
|
|
179
|
-
expect(hash[
|
|
186
|
+
expect(hash["id"]).to eq("US-001")
|
|
187
|
+
expect(hash["body"]).to eq("US")
|
|
188
|
+
expect(hash["locality"]["clause"]).to eq("5.1")
|
|
189
|
+
expect(hash["type"]).to eq("technical")
|
|
190
|
+
expect(hash["comments"]).to eq("Test")
|
|
191
|
+
expect(hash["proposed_change"]).to eq("Change")
|
|
192
|
+
expect(hash["observations"]).to eq("Obs")
|
|
180
193
|
end
|
|
181
194
|
end
|
|
182
195
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Commenter::DispositionStatus do
|
|
6
|
+
describe ".match" do
|
|
7
|
+
it "classifies accepted dispositions" do
|
|
8
|
+
expect(described_class.match("Accepted")).to eq(:accepted)
|
|
9
|
+
expect(described_class.match("accepted.")).to eq(:accepted)
|
|
10
|
+
expect(described_class.match("Partially accepted")).to eq(:accepted)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "classifies accept-with-modifications before the bare accept pattern" do
|
|
14
|
+
expect(described_class.match("Accept with modifications")).to eq(:accept_with_modifications)
|
|
15
|
+
expect(described_class.match("Accepted with modifications")).to eq(:accept_with_modifications)
|
|
16
|
+
expect(described_class.match("Accepted, with changes")).to eq(:accept_with_modifications)
|
|
17
|
+
expect(described_class.match("AWM")).to eq(:accept_with_modifications)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "classifies rejection including the 'not accepted' phrasing" do
|
|
21
|
+
expect(described_class.match("Rejected")).to eq(:rejected)
|
|
22
|
+
expect(described_class.match("Not accepted")).to eq(:rejected)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "classifies noted, todo, and non-matching text" do
|
|
26
|
+
expect(described_class.match("Noted")).to eq(:noted)
|
|
27
|
+
expect(described_class.match("TODO: review")).to eq(:todo)
|
|
28
|
+
expect(described_class.match("Deferred to next revision")).to be_nil
|
|
29
|
+
expect(described_class.match("")).to be_nil
|
|
30
|
+
expect(described_class.match(nil)).to be_nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe ".statuses" do
|
|
35
|
+
it "lists the canonical statuses" do
|
|
36
|
+
expect(described_class.statuses)
|
|
37
|
+
.to eq(%i[accept_with_modifications rejected accepted noted todo])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|