commenter 0.3.0 → 0.4.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/CLAUDE.md +17 -7
- data/CONTEXT.md +27 -0
- data/README.adoc +92 -17
- data/commenter.gemspec +1 -0
- data/lib/commenter/ballot.rb +67 -0
- data/lib/commenter/ballot_report.rb +60 -0
- data/lib/commenter/cli.rb +62 -43
- data/lib/commenter/comment.rb +103 -126
- data/lib/commenter/comment_locality.rb +18 -0
- data/lib/commenter/comment_sheet.rb +31 -63
- data/lib/commenter/comment_type.rb +39 -0
- 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 +17 -71
- data/lib/commenter/github_session.rb +35 -0
- data/lib/commenter/parser/osd_xlsx_parser.rb +1 -12
- data/lib/commenter/parser/track_change_docx_parser.rb +330 -0
- data/lib/commenter/parser.rb +10 -13
- data/lib/commenter/version.rb +1 -1
- data/lib/commenter.rb +16 -5
- 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 +35 -10
- data/spec/commenter/comment_spec.rb +22 -9
- data/spec/commenter/comment_type_spec.rb +45 -0
- data/spec/commenter/disposition_status_spec.rb +40 -0
- data/spec/commenter/filler_spec.rb +119 -0
- data/spec/commenter/github_integration_spec.rb +65 -32
- data/spec/commenter/osd_xlsx_parser_spec.rb +3 -2
- data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
- data/spec/support/redline_docx_builder.rb +95 -0
- data/spec/support/xlsx_builder.rb +17 -12
- metadata +32 -2
data/lib/commenter/comment.rb
CHANGED
|
@@ -1,180 +1,157 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
3
5
|
module Commenter
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
6
|
+
# A single comment on a reviewed document. (De)serialization is declared
|
|
7
|
+
# with lutaml-model; the only custom mapping logic is the type expansion
|
|
8
|
+
# (short codes to full names) and empty-observation omission, both at the
|
|
9
|
+
# YAML boundary.
|
|
10
|
+
class Comment < Lutaml::Model::Serializable
|
|
11
|
+
attribute :id, :string
|
|
12
|
+
attribute :body, :string
|
|
13
|
+
attribute :locality, CommentLocality
|
|
14
|
+
attribute :type, :string
|
|
15
|
+
attribute :comments, :string
|
|
16
|
+
attribute :proposed_change, :string
|
|
17
|
+
attribute :observations, :string
|
|
18
|
+
|
|
19
|
+
# OSD-specific fields
|
|
20
|
+
attribute :user_name, :string
|
|
21
|
+
attribute :comment_type, :string
|
|
22
|
+
attribute :resolution_status, :string
|
|
23
|
+
attribute :resolution_date, :string
|
|
24
|
+
attribute :feedbacks, :string
|
|
25
|
+
attribute :motivation, :string
|
|
26
|
+
attribute :created_date, :string
|
|
27
|
+
attribute :stage_code, :string
|
|
28
|
+
|
|
29
|
+
attribute :github, GithubInfo
|
|
30
|
+
|
|
31
|
+
yaml do
|
|
32
|
+
map "id", to: :id
|
|
33
|
+
map "body", to: :body
|
|
34
|
+
map "locality", to: :locality
|
|
35
|
+
map "type", with: { to: :type_to_yaml, from: :type_from_yaml }
|
|
36
|
+
map "comments", to: :comments
|
|
37
|
+
map "proposed_change", to: :proposed_change
|
|
38
|
+
map "observations", with: { to: :observations_to_yaml, from: :observations_from_yaml }
|
|
39
|
+
map "user_name", to: :user_name
|
|
40
|
+
map "comment_type", to: :comment_type
|
|
41
|
+
map "resolution_status", to: :resolution_status
|
|
42
|
+
map "resolution_date", to: :resolution_date
|
|
43
|
+
map "feedbacks", to: :feedbacks
|
|
44
|
+
map "motivation", to: :motivation
|
|
45
|
+
map "created_date", to: :created_date
|
|
46
|
+
map "stage_code", to: :stage_code
|
|
47
|
+
map "github", to: :github
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def type_from_yaml(model, value)
|
|
51
|
+
model.type = CommentType.full_name(value)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def type_to_yaml(model, doc)
|
|
55
|
+
doc["type"] = model.type if model.type && !model.type.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def observations_from_yaml(model, value)
|
|
59
|
+
model.observations = value.to_s.empty? ? nil : value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def observations_to_yaml(model, doc)
|
|
63
|
+
doc["observations"] = model.observations if model.observations && !model.observations.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
36
66
|
def expand_comment_type(type)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
67
|
+
CommentType.full_name(type)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_h
|
|
71
|
+
Comment.to_hash(self)
|
|
43
72
|
end
|
|
44
73
|
|
|
74
|
+
alias to_yaml_h to_h
|
|
75
|
+
|
|
45
76
|
def line_number
|
|
46
|
-
|
|
77
|
+
locality&.line_number
|
|
47
78
|
end
|
|
48
79
|
|
|
49
80
|
def line_number=(value)
|
|
50
|
-
|
|
81
|
+
self.locality = CommentLocality.new if locality.nil?
|
|
82
|
+
locality.line_number = value
|
|
51
83
|
end
|
|
52
84
|
|
|
53
85
|
def clause
|
|
54
|
-
|
|
86
|
+
locality&.clause
|
|
55
87
|
end
|
|
56
88
|
|
|
57
89
|
def clause=(value)
|
|
58
|
-
|
|
90
|
+
self.locality = CommentLocality.new if locality.nil?
|
|
91
|
+
locality.clause = value
|
|
59
92
|
end
|
|
60
93
|
|
|
61
94
|
def element
|
|
62
|
-
|
|
95
|
+
locality&.element
|
|
63
96
|
end
|
|
64
97
|
|
|
65
98
|
def element=(value)
|
|
66
|
-
|
|
99
|
+
self.locality = CommentLocality.new if locality.nil?
|
|
100
|
+
locality.element = value
|
|
67
101
|
end
|
|
68
102
|
|
|
69
|
-
def brief_summary(max_length =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
# Add locality information first
|
|
73
|
-
parts << "Clause #{clause}" if clause && !clause.strip.empty?
|
|
74
|
-
parts << element if element && !element.strip.empty?
|
|
75
|
-
parts << "Line #{line_number}" if line_number && !line_number.strip.empty?
|
|
103
|
+
def brief_summary(max_length = 60)
|
|
104
|
+
description = comments.to_s.split(/[.!?\n]/).map(&:strip).reject(&:empty?).first.to_s
|
|
105
|
+
locality_text = format_locality
|
|
76
106
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
# Add description from comment text
|
|
80
|
-
if @comments && !@comments.strip.empty?
|
|
81
|
-
# Extract first sentence or truncate
|
|
82
|
-
clean_text = @comments.strip.gsub(/\s+/, " ")
|
|
83
|
-
first_sentence = clean_text.split(/[.!?]/).first&.strip
|
|
84
|
-
description = if first_sentence && first_sentence.length < max_length
|
|
85
|
-
first_sentence
|
|
86
|
-
else
|
|
87
|
-
clean_text[0...50]
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
if locality_text.empty?
|
|
91
|
-
description
|
|
92
|
-
else
|
|
93
|
-
# Combine locality + description, respecting max_length
|
|
94
|
-
combined = "#{locality_text}: #{description}"
|
|
95
|
-
combined.length <= max_length ? combined : "#{locality_text}: #{description[0...(max_length - locality_text.length - 2)]}"
|
|
96
|
-
end
|
|
97
|
-
else
|
|
107
|
+
if description.empty?
|
|
98
108
|
locality_text.empty? ? "No description" : locality_text
|
|
109
|
+
elsif locality_text.empty?
|
|
110
|
+
combined = description
|
|
111
|
+
combined.length <= max_length ? combined : "#{combined[0...(max_length - 3)]}..."
|
|
112
|
+
else
|
|
113
|
+
combined = "#{locality_text}: #{description}"
|
|
114
|
+
combined.length <= max_length ? combined : "#{locality_text}: #{description[0...(max_length - locality_text.length - 2)]}"
|
|
99
115
|
end
|
|
100
116
|
end
|
|
101
117
|
|
|
102
118
|
def github_issue_number
|
|
103
|
-
|
|
119
|
+
github&.issue_number
|
|
104
120
|
end
|
|
105
121
|
|
|
106
122
|
def github_issue_url
|
|
107
|
-
|
|
123
|
+
github&.issue_url
|
|
108
124
|
end
|
|
109
125
|
|
|
110
126
|
def github_status
|
|
111
|
-
|
|
127
|
+
github&.status
|
|
112
128
|
end
|
|
113
129
|
|
|
114
130
|
def github_created_at
|
|
115
|
-
|
|
131
|
+
github&.created_at
|
|
116
132
|
end
|
|
117
133
|
|
|
118
134
|
def github_updated_at
|
|
119
|
-
|
|
135
|
+
github&.updated_at
|
|
120
136
|
end
|
|
121
137
|
|
|
122
138
|
def has_github_issue?
|
|
123
|
-
|
|
139
|
+
!github&.issue_number.nil?
|
|
124
140
|
end
|
|
125
141
|
|
|
126
|
-
def
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
body: @body,
|
|
130
|
-
locality: @locality,
|
|
131
|
-
type: @type,
|
|
132
|
-
comments: @comments,
|
|
133
|
-
proposed_change: @proposed_change,
|
|
134
|
-
observations: @observations,
|
|
135
|
-
user_name: @user_name,
|
|
136
|
-
comment_type: @comment_type,
|
|
137
|
-
resolution_status: @resolution_status,
|
|
138
|
-
resolution_date: @resolution_date,
|
|
139
|
-
feedbacks: @feedbacks,
|
|
140
|
-
motivation: @motivation,
|
|
141
|
-
created_date: @created_date,
|
|
142
|
-
stage_code: @stage_code,
|
|
143
|
-
github: @github.empty? ? nil : @github
|
|
144
|
-
}.compact
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def to_yaml_h
|
|
148
|
-
hash = to_h
|
|
149
|
-
# Remove observations if it's nil or empty
|
|
150
|
-
hash.delete(:observations) if hash[:observations].nil? || hash[:observations] == ""
|
|
151
|
-
stringify_keys(hash)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def self.from_hash(hash)
|
|
155
|
-
new(hash)
|
|
142
|
+
def record_github_issue(issue_number:, issue_url:, status:, created_at: nil)
|
|
143
|
+
self.github = GithubInfo.new(issue_number: issue_number, issue_url: issue_url,
|
|
144
|
+
status: status, created_at: created_at)
|
|
156
145
|
end
|
|
157
146
|
|
|
158
147
|
private
|
|
159
148
|
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
result[new_key] = new_value
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def stringify_keys(hash)
|
|
171
|
-
return hash unless hash.is_a?(Hash)
|
|
172
|
-
|
|
173
|
-
hash.each_with_object({}) do |(key, value), result|
|
|
174
|
-
new_key = key.to_s
|
|
175
|
-
new_value = value.is_a?(Hash) ? stringify_keys(value) : value
|
|
176
|
-
result[new_key] = new_value
|
|
177
|
-
end
|
|
149
|
+
def format_locality
|
|
150
|
+
parts = []
|
|
151
|
+
parts << "Clause #{clause}" if clause && !clause.strip.empty?
|
|
152
|
+
parts << element if element && !element.strip.empty?
|
|
153
|
+
parts << "Line #{line_number}" if line_number && !line_number.strip.empty?
|
|
154
|
+
parts.join(", ")
|
|
178
155
|
end
|
|
179
156
|
end
|
|
180
157
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Commenter
|
|
6
|
+
# Location of a comment within the reviewed document.
|
|
7
|
+
class CommentLocality < Lutaml::Model::Serializable
|
|
8
|
+
attribute :line_number, :string
|
|
9
|
+
attribute :clause, :string
|
|
10
|
+
attribute :element, :string
|
|
11
|
+
|
|
12
|
+
yaml do
|
|
13
|
+
map "line_number", to: :line_number
|
|
14
|
+
map "clause", to: :clause
|
|
15
|
+
map "element", to: :element
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -1,81 +1,49 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "lutaml/model"
|
|
4
4
|
|
|
5
5
|
module Commenter
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
6
|
+
# One ballot submission: metadata plus its comments. Serialization is
|
|
7
|
+
# declared with lutaml-model.
|
|
8
|
+
class CommentSheet < Lutaml::Model::Serializable
|
|
9
|
+
attribute :version, :string, default: -> { "2012-03" }
|
|
10
|
+
attribute :date, :string
|
|
11
|
+
attribute :document, :string
|
|
12
|
+
attribute :project, :string
|
|
13
|
+
attribute :stage, :string
|
|
14
|
+
attribute :title_en, :string
|
|
15
|
+
attribute :title_fr, :string
|
|
16
|
+
attribute :comments, Comment, collection: true
|
|
17
|
+
|
|
18
|
+
yaml do
|
|
19
|
+
map "version", to: :version
|
|
20
|
+
map "date", to: :date
|
|
21
|
+
map "document", to: :document
|
|
22
|
+
map "project", to: :project
|
|
23
|
+
map "stage", to: :stage
|
|
24
|
+
map "title_en", to: :title_en
|
|
25
|
+
map "title_fr", to: :title_fr
|
|
26
|
+
map "comments", to: :comments
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
def add_comment(comment)
|
|
24
|
-
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def to_h
|
|
28
|
-
{
|
|
29
|
-
version: @version,
|
|
30
|
-
date: @date,
|
|
31
|
-
document: @document,
|
|
32
|
-
project: @project,
|
|
33
|
-
stage: @stage,
|
|
34
|
-
title_en: @title_en,
|
|
35
|
-
title_fr: @title_fr,
|
|
36
|
-
comments: @comments.map(&:to_h)
|
|
37
|
-
}
|
|
30
|
+
self.comments = (comments || []) + [comment.is_a?(Comment) ? comment : Comment.from_hash(comment)]
|
|
38
31
|
end
|
|
39
32
|
|
|
40
|
-
def
|
|
41
|
-
|
|
42
|
-
# Remove nil-valued keys for cleaner YAML output
|
|
43
|
-
hash.delete(:title_en) if hash[:title_en].nil?
|
|
44
|
-
hash.delete(:title_fr) if hash[:title_fr].nil?
|
|
45
|
-
stringify_keys(hash)
|
|
33
|
+
def schema_name
|
|
34
|
+
version == "osd" ? "iso_comment_osd.yaml" : "iso_comment_2012-03.yaml"
|
|
46
35
|
end
|
|
47
36
|
|
|
48
|
-
def
|
|
49
|
-
|
|
37
|
+
def to_yaml_document(schema_dir = "schema")
|
|
38
|
+
"# yaml-language-server: $schema=#{File.join(schema_dir.to_s, schema_name)}\n\n#{to_yaml}"
|
|
50
39
|
end
|
|
51
40
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def symbolize_keys(hash)
|
|
55
|
-
return hash unless hash.is_a?(Hash)
|
|
56
|
-
|
|
57
|
-
hash.each_with_object({}) do |(key, value), result|
|
|
58
|
-
new_key = key.to_sym
|
|
59
|
-
new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
|
|
60
|
-
result[new_key] = new_value
|
|
61
|
-
end
|
|
41
|
+
def to_yaml_h
|
|
42
|
+
CommentSheet.to_hash(self)
|
|
62
43
|
end
|
|
63
44
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
hash.each_with_object({}) do |(key, value), result|
|
|
68
|
-
new_key = key.to_s
|
|
69
|
-
new_value = case value
|
|
70
|
-
when Hash
|
|
71
|
-
stringify_keys(value)
|
|
72
|
-
when Array
|
|
73
|
-
value.map { |item| item.is_a?(Hash) ? stringify_keys(item) : item }
|
|
74
|
-
else
|
|
75
|
-
value
|
|
76
|
-
end
|
|
77
|
-
result[new_key] = new_value
|
|
78
|
-
end
|
|
45
|
+
def to_h
|
|
46
|
+
CommentSheet.to_hash(self)
|
|
79
47
|
end
|
|
80
48
|
end
|
|
81
49
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commenter
|
|
4
|
+
# Single home for the comment type vocabulary: short codes (ge/te/ed) and
|
|
5
|
+
# their expanded names (general/technical/editorial). Unrecognized values
|
|
6
|
+
# pass through unchanged; display_name capitalizes recognized types.
|
|
7
|
+
module CommentType
|
|
8
|
+
FULL_NAMES = {
|
|
9
|
+
"ge" => "general",
|
|
10
|
+
"te" => "technical",
|
|
11
|
+
"ed" => "editorial"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
CODES = FULL_NAMES.invert.freeze
|
|
15
|
+
|
|
16
|
+
DISPLAY_NAMES = FULL_NAMES.each_with_object({}) do |(code, full), display|
|
|
17
|
+
display[code] = full.capitalize
|
|
18
|
+
display[full] = full.capitalize
|
|
19
|
+
end.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def code(type)
|
|
24
|
+
CODES.fetch(type.to_s.strip.downcase) { type&.to_s&.strip }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def full_name(type)
|
|
28
|
+
return nil if type.nil?
|
|
29
|
+
|
|
30
|
+
value = type.to_s.strip
|
|
31
|
+
downcased = value.downcase
|
|
32
|
+
FULL_NAMES[downcased] || (FULL_NAMES.values.include?(downcased) ? downcased : value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def display_name(type)
|
|
36
|
+
DISPLAY_NAMES.fetch(type.to_s.strip.downcase) { type || "Unknown" }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Commenter
|
|
4
|
+
# Canonical disposition vocabulary, shared by cell shading (Filler) and
|
|
5
|
+
# ballot statistics (BallotReport). Matches free-text observations and
|
|
6
|
+
# OSD's structured resolution_status values. One home for the question
|
|
7
|
+
# "what counts as an accepted disposition".
|
|
8
|
+
module DispositionStatus
|
|
9
|
+
PATTERNS = [
|
|
10
|
+
[:accept_with_modifications, /awm|accept(ed)?[ ,]*with +(modifications|changes)/].freeze,
|
|
11
|
+
[:rejected, /reject(ed)?|not accepted/].freeze,
|
|
12
|
+
[:accepted, /accept(ed)?/].freeze,
|
|
13
|
+
[:noted, /noted/].freeze,
|
|
14
|
+
[:todo, /todo/].freeze
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Returns the canonical status symbol for the given text, or nil when
|
|
20
|
+
# nothing matches. Order matters: "accept with modifications" and
|
|
21
|
+
# "not accepted" must be classified before the bare "accept" pattern.
|
|
22
|
+
def match(text)
|
|
23
|
+
value = text.to_s.downcase.strip
|
|
24
|
+
return nil if value.empty?
|
|
25
|
+
|
|
26
|
+
PATTERNS.each do |status, pattern|
|
|
27
|
+
return status if value.match?(pattern)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def statuses
|
|
34
|
+
PATTERNS.map(&:first)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|