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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/CLAUDE.md +17 -7
  4. data/CONTEXT.md +27 -0
  5. data/README.adoc +92 -17
  6. data/commenter.gemspec +1 -0
  7. data/lib/commenter/ballot.rb +67 -0
  8. data/lib/commenter/ballot_report.rb +60 -0
  9. data/lib/commenter/cli.rb +62 -43
  10. data/lib/commenter/comment.rb +103 -126
  11. data/lib/commenter/comment_locality.rb +18 -0
  12. data/lib/commenter/comment_sheet.rb +31 -63
  13. data/lib/commenter/comment_type.rb +39 -0
  14. data/lib/commenter/disposition_status.rb +37 -0
  15. data/lib/commenter/filler.rb +91 -59
  16. data/lib/commenter/github_info.rb +23 -0
  17. data/lib/commenter/github_integration.rb +17 -71
  18. data/lib/commenter/github_session.rb +35 -0
  19. data/lib/commenter/parser/osd_xlsx_parser.rb +1 -12
  20. data/lib/commenter/parser/track_change_docx_parser.rb +330 -0
  21. data/lib/commenter/parser.rb +10 -13
  22. data/lib/commenter/version.rb +1 -1
  23. data/lib/commenter.rb +16 -5
  24. data/spec/commenter/ballot_report_spec.rb +62 -0
  25. data/spec/commenter/ballot_spec.rb +68 -0
  26. data/spec/commenter/cli_spec.rb +41 -0
  27. data/spec/commenter/comment_sheet_spec.rb +35 -10
  28. data/spec/commenter/comment_spec.rb +22 -9
  29. data/spec/commenter/comment_type_spec.rb +45 -0
  30. data/spec/commenter/disposition_status_spec.rb +40 -0
  31. data/spec/commenter/filler_spec.rb +119 -0
  32. data/spec/commenter/github_integration_spec.rb +65 -32
  33. data/spec/commenter/osd_xlsx_parser_spec.rb +3 -2
  34. data/spec/commenter/track_change_docx_parser_spec.rb +277 -0
  35. data/spec/support/redline_docx_builder.rb +95 -0
  36. data/spec/support/xlsx_builder.rb +17 -12
  37. metadata +32 -2
@@ -1,180 +1,157 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "lutaml/model"
4
+
3
5
  module Commenter
4
- class Comment
5
- attr_accessor :id, :body, :locality, :type, :comments, :proposed_change, :observations, :github,
6
- :user_name, :comment_type, :resolution_status, :resolution_date, :feedbacks,
7
- :motivation, :created_date, :stage_code
8
-
9
- def initialize(attributes = {})
10
- # Normalize input to symbols
11
- attrs = symbolize_keys(attributes)
12
-
13
- @id = attrs[:id]
14
- @body = attrs[:body]
15
- @locality = symbolize_keys(attrs[:locality] || {})
16
- @type = expand_comment_type(attrs[:type])
17
- @comments = attrs[:comments]
18
- @proposed_change = attrs[:proposed_change]
19
- @observations = attrs[:observations]
20
- @github = symbolize_keys(attrs[:github] || {})
21
-
22
- # OSD-specific fields
23
- @user_name = attrs[:user_name]
24
- @comment_type = attrs[:comment_type]
25
- @resolution_status = attrs[:resolution_status]
26
- @resolution_date = attrs[:resolution_date]
27
- @feedbacks = attrs[:feedbacks]
28
- @motivation = attrs[:motivation]
29
- @created_date = attrs[:created_date]
30
- @stage_code = attrs[:stage_code]
31
- end
32
-
33
- # Expands short type codes to full names. One-way by design: already
34
- # expanded values pass through unchanged so that reloading YAML output
35
- # stays stable.
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
- case type&.downcase
38
- when "ge" then "general"
39
- when "te" then "technical"
40
- when "ed" then "editorial"
41
- else type
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
- @locality[:line_number]
77
+ locality&.line_number
47
78
  end
48
79
 
49
80
  def line_number=(value)
50
- @locality[:line_number] = value
81
+ self.locality = CommentLocality.new if locality.nil?
82
+ locality.line_number = value
51
83
  end
52
84
 
53
85
  def clause
54
- @locality[:clause]
86
+ locality&.clause
55
87
  end
56
88
 
57
89
  def clause=(value)
58
- @locality[:clause] = value
90
+ self.locality = CommentLocality.new if locality.nil?
91
+ locality.clause = value
59
92
  end
60
93
 
61
94
  def element
62
- @locality[:element]
95
+ locality&.element
63
96
  end
64
97
 
65
98
  def element=(value)
66
- @locality[:element] = value
99
+ self.locality = CommentLocality.new if locality.nil?
100
+ locality.element = value
67
101
  end
68
102
 
69
- def brief_summary(max_length = 80)
70
- parts = []
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
- locality_text = parts.join(", ")
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
- @github[:issue_number]
119
+ github&.issue_number
104
120
  end
105
121
 
106
122
  def github_issue_url
107
- @github[:issue_url]
123
+ github&.issue_url
108
124
  end
109
125
 
110
126
  def github_status
111
- @github[:status]
127
+ github&.status
112
128
  end
113
129
 
114
130
  def github_created_at
115
- @github[:created_at]
131
+ github&.created_at
116
132
  end
117
133
 
118
134
  def github_updated_at
119
- @github[:updated_at]
135
+ github&.updated_at
120
136
  end
121
137
 
122
138
  def has_github_issue?
123
- !@github[:issue_number].nil?
139
+ !github&.issue_number.nil?
124
140
  end
125
141
 
126
- def to_h
127
- {
128
- id: @id,
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 symbolize_keys(hash)
161
- return hash unless hash.is_a?(Hash)
162
-
163
- hash.each_with_object({}) do |(key, value), result|
164
- new_key = key.to_sym
165
- new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
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
- require_relative "comment"
3
+ require "lutaml/model"
4
4
 
5
5
  module Commenter
6
- class CommentSheet
7
- attr_accessor :version, :date, :document, :project, :stage, :comments, :title_en, :title_fr
8
-
9
- def initialize(attributes = {})
10
- # Normalize input to symbols
11
- attrs = symbolize_keys(attributes)
12
-
13
- @version = attrs[:version] || "2012-03"
14
- @date = attrs[:date]
15
- @document = attrs[:document]
16
- @project = attrs[:project]
17
- @stage = attrs[:stage]
18
- @title_en = attrs[:title_en]
19
- @title_fr = attrs[:title_fr]
20
- @comments = (attrs[:comments] || []).map { |c| c.is_a?(Comment) ? c : Comment.from_hash(c) }
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
- @comments << (comment.is_a?(Comment) ? comment : Comment.from_hash(comment))
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 to_yaml_h
41
- hash = to_h.merge(comments: @comments.map(&:to_yaml_h))
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 self.from_hash(hash)
49
- new(hash)
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
- private
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 stringify_keys(hash)
65
- return hash unless hash.is_a?(Hash)
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