commenter 0.3.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1609bb9eed80e408daebc9c67cda2c657e8484186acce89b823e6b411a5f9ea5
4
- data.tar.gz: 46316674e2c639b9256390385d2cb3c59ed8946b604b44fbb0be28728475cd38
3
+ metadata.gz: 90d4268c6a071ae365af24ef05a5c19dcdfd6fe03545c05a84767f91f70dc411
4
+ data.tar.gz: b44468b6f9cc18e0726c1df1c1433247e8fc19f6ff9fafc4c3f0eff341a21785
5
5
  SHA512:
6
- metadata.gz: 887aa19ab8ca3559fd20caf3e19a9941b631c677faeda86553597671883fc8dbae8c1e1f6d83bd0014e036774743addc16bc538b7404f6939be6636e1d623704
7
- data.tar.gz: 3b850d0872f2f572b599d0bf7a70a977e1e5e7e797f5fb3447905c09cc7578cff18d99de6b32ae3b539758b7319b07590fdfd9d5249f74024ffdcd842d48d09d
6
+ metadata.gz: 00d4838eadd5c0751199832ff6401a753b2bc4c27cd94d02f6c0d2bab6392a629c216c6cc350766c0d35920f871b4e2bd74102967460e370ebc15fc005a17e9c
7
+ data.tar.gz: '09420029845d4e6b776eeb40b726326486a409dd8f0ec33be792dcc193e30f5d5774e99777cd64e85c31a5137a69d2648eac4ab06bde2a5ea93a7d2569d0e90a'
data/CONTEXT.md ADDED
@@ -0,0 +1,27 @@
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
+ - **Unique ID** — stage-aware identity of a comment's GitHub issue
27
+ (`[DIS] GB-001` by default), used for duplicate detection.
data/README.adoc CHANGED
@@ -124,12 +124,16 @@ Options:
124
124
  ==== Metadata (extraction limitation)
125
125
 
126
126
  WARNING: Due to a limitation in the underlying `docx` gem, metadata fields
127
- (Date, Document, Project) from the DOCX header cannot be automatically
128
- extracted. This is because the required functionality is not yet merged (see
129
- https://github.com/ruby-docx/docx/pull/73[docx gem PR #73]).
127
+ (Date, Document, Project) cannot yet be automatically extracted from the DOCX
128
+ header during import (see https://github.com/ruby-docx/docx/pull/73[docx gem
129
+ PR #73]). Redline imports take them from the `--document` / `--stage` options;
130
+ for template imports, populate the YAML fields manually.
130
131
 
131
- The generated YAML will have empty metadata fields that you can manually
132
- populate:
132
+ The reverse direction works: `commenter fill` writes the sheet's
133
+ `date`, `document`, and `project` values into the Date/Document/Project labels
134
+ of the template's page header, so the filled comment sheet is complete.
135
+
136
+ For template imports, populate the YAML metadata manually:
133
137
 
134
138
  [source,yaml]
135
139
  ----
@@ -142,9 +146,6 @@ project: "" # Manually add: e.g., "Project name"
142
146
  comments:
143
147
  ----
144
148
 
145
- Alternatively, you can provide metadata during import using CLI options (planned
146
- for future release).
147
-
148
149
 
149
150
  ==== Example YAML output
150
151
 
@@ -189,11 +190,56 @@ 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
+
192
237
  === Filling DOCX templates from YAML
193
238
 
194
239
  This gem contains a command-line utility to fill a DOCX template with comments
195
240
  from a YAML file. It generates a filled comment sheet that can be used for
196
- review and resolution tracking.
241
+ review and resolution tracking. The sheet's `date`, `document`, and `project`
242
+ metadata are written into the template's page header.
197
243
 
198
244
  The base template is the ISO comment sheet template located at
199
245
  `data/iso_comment_template_2012-03.docx`. You can also provide a custom
@@ -605,10 +651,10 @@ recognized and applied to the observations column:
605
651
  |===
606
652
  | Status Pattern | Intended Color | Hex Code | Example
607
653
 
654
+ | `awm\|accept(ed)? with (modifications\|changes)` | Olive Green | #C4D79B | "Accepted with modifications"
608
655
  | `accept(ed)?` | Green | #92D050 | "Accepted"
609
- | `awm\|accept with modifications` | Olive Green | #C4D79B | "Accept with modifications"
610
656
  | `noted` | Blue | #8DB4E2 | "Noted"
611
- | `reject(ed)?` | Pink | #FF99CC | "Rejected"
657
+ | `reject(ed)?\|not accepted` | Pink | #FF99CC | "Rejected"
612
658
  | `todo` | Diagonal stripes | #D9D9D9 | "TODO: Review"
613
659
 
614
660
  |===
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,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,50 @@ module Commenter
21
21
  option :document, type: :string, desc: "Redline: document identifier (e.g. ISO 2533:2026)"
22
22
  option :stage, type: :string, desc: "Redline: approval stage (e.g. DIS)"
23
23
  def import(input_file)
24
- output_yaml = options[:output]
25
- schema_dir = options[:schema_dir]
26
-
27
- # Ensure schema directory exists
28
- FileUtils.mkdir_p(schema_dir) unless Dir.exist?(schema_dir)
29
-
30
24
  # Parse the input file
31
- parser = Parser.new
32
- comment_sheet = parser.parse(input_file, options)
33
-
34
- # Write the YAML data file with schema reference
35
- File.write(output_yaml, comment_sheet.to_yaml_document(schema_dir))
36
-
37
- # Copy schema file to output directory
38
- schema_name = comment_sheet.schema_name
39
- schema_source = File.join(__dir__, "../../schema/#{schema_name}")
40
- schema_target = File.join(schema_dir, schema_name)
25
+ comment_sheet = Parser.new.parse(input_file, options)
41
26
 
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)
27
+ schema_target = write_sheet(comment_sheet, options[:output], options[:schema_dir])
44
28
 
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
+
51
68
  desc "fill INPUT.yaml", "Fill DOCX template from YAML comments"
52
69
  option :output, type: :string, aliases: :o, default: "filled_comments.docx", desc: "Output DOCX file"
53
70
  option :template, type: :string, aliases: :t, desc: "Custom template file"
@@ -55,23 +72,20 @@ module Commenter
55
72
  def fill(input_yaml)
56
73
  output_docx = options[:output]
57
74
 
58
- # Load YAML data
59
- data = YAML.load_file(input_yaml)
60
-
61
- # Extract comments from the structure
62
- comments = if data.is_a?(Hash)
63
- data["comments"] || data[:comments] || []
64
- else
65
- data || []
66
- end
67
-
75
+ comment_sheet = CommentSheet.from_yaml(File.read(input_yaml))
76
+ comments = comment_sheet.comments
68
77
  raise "No comments found in YAML file" if comments.empty?
69
78
 
70
79
  # Use default template if none specified
71
80
  template_path = options[:template] || File.join(__dir__, "../../data/iso_comment_template_2012-03.docx")
72
81
 
73
- # Fill the template
74
- Filler.new.fill(template_path, output_docx, comments, options)
82
+ # Fill the template, including the sheet metadata in the page header
83
+ fill_options = options.merge(
84
+ date: comment_sheet.date,
85
+ document: comment_sheet.document,
86
+ project: comment_sheet.project
87
+ ).compact
88
+ Filler.new.fill(template_path, output_docx, comments, fill_options)
75
89
  puts "Filled template to #{output_docx}"
76
90
  end
77
91
 
@@ -212,5 +226,18 @@ module Commenter
212
226
  def self.exit_on_failure?
213
227
  true
214
228
  end
229
+
230
+ private
231
+
232
+ def write_sheet(comment_sheet, output_yaml, schema_dir)
233
+ FileUtils.mkdir_p(schema_dir) unless Dir.exist?(schema_dir)
234
+ File.write(output_yaml, comment_sheet.to_yaml_document(schema_dir))
235
+
236
+ schema_name = comment_sheet.schema_name
237
+ schema_source = File.join(__dir__, "../../schema/#{schema_name}")
238
+ schema_target = File.join(schema_dir, schema_name)
239
+ FileUtils.cp(schema_source, schema_target) unless File.expand_path(schema_source) == File.expand_path(schema_target)
240
+ schema_target
241
+ end
215
242
  end
216
243
  end
@@ -1,172 +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 = CommentType.full_name(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]
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?
31
64
  end
32
65
 
33
66
  def expand_comment_type(type)
34
67
  CommentType.full_name(type)
35
68
  end
36
69
 
70
+ def to_h
71
+ Comment.to_hash(self)
72
+ end
73
+
74
+ alias to_yaml_h to_h
75
+
37
76
  def line_number
38
- @locality[:line_number]
77
+ locality&.line_number
39
78
  end
40
79
 
41
80
  def line_number=(value)
42
- @locality[:line_number] = value
81
+ self.locality = CommentLocality.new if locality.nil?
82
+ locality.line_number = value
43
83
  end
44
84
 
45
85
  def clause
46
- @locality[:clause]
86
+ locality&.clause
47
87
  end
48
88
 
49
89
  def clause=(value)
50
- @locality[:clause] = value
90
+ self.locality = CommentLocality.new if locality.nil?
91
+ locality.clause = value
51
92
  end
52
93
 
53
94
  def element
54
- @locality[:element]
95
+ locality&.element
55
96
  end
56
97
 
57
98
  def element=(value)
58
- @locality[:element] = value
99
+ self.locality = CommentLocality.new if locality.nil?
100
+ locality.element = value
59
101
  end
60
102
 
61
- def brief_summary(max_length = 80)
62
- parts = []
63
-
64
- # Add locality information first
65
- parts << "Clause #{clause}" if clause && !clause.strip.empty?
66
- parts << element if element && !element.strip.empty?
67
- 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
68
106
 
69
- locality_text = parts.join(", ")
70
-
71
- # Add description from comment text
72
- if @comments && !@comments.strip.empty?
73
- # Extract first sentence or truncate
74
- clean_text = @comments.strip.gsub(/\s+/, " ")
75
- first_sentence = clean_text.split(/[.!?]/).first&.strip
76
- description = if first_sentence && first_sentence.length < max_length
77
- first_sentence
78
- else
79
- clean_text[0...50]
80
- end
81
-
82
- if locality_text.empty?
83
- description
84
- else
85
- # Combine locality + description, respecting max_length
86
- combined = "#{locality_text}: #{description}"
87
- combined.length <= max_length ? combined : "#{locality_text}: #{description[0...(max_length - locality_text.length - 2)]}"
88
- end
89
- else
107
+ if description.empty?
90
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)]}"
91
115
  end
92
116
  end
93
117
 
94
118
  def github_issue_number
95
- @github[:issue_number]
119
+ github&.issue_number
96
120
  end
97
121
 
98
122
  def github_issue_url
99
- @github[:issue_url]
123
+ github&.issue_url
100
124
  end
101
125
 
102
126
  def github_status
103
- @github[:status]
127
+ github&.status
104
128
  end
105
129
 
106
130
  def github_created_at
107
- @github[:created_at]
131
+ github&.created_at
108
132
  end
109
133
 
110
134
  def github_updated_at
111
- @github[:updated_at]
135
+ github&.updated_at
112
136
  end
113
137
 
114
138
  def has_github_issue?
115
- !@github[:issue_number].nil?
139
+ !github&.issue_number.nil?
116
140
  end
117
141
 
118
- def to_h
119
- {
120
- id: @id,
121
- body: @body,
122
- locality: @locality,
123
- type: @type,
124
- comments: @comments,
125
- proposed_change: @proposed_change,
126
- observations: @observations,
127
- user_name: @user_name,
128
- comment_type: @comment_type,
129
- resolution_status: @resolution_status,
130
- resolution_date: @resolution_date,
131
- feedbacks: @feedbacks,
132
- motivation: @motivation,
133
- created_date: @created_date,
134
- stage_code: @stage_code,
135
- github: @github.empty? ? nil : @github
136
- }.compact
137
- end
138
-
139
- def to_yaml_h
140
- hash = to_h
141
- # Remove observations if it's nil or empty
142
- hash.delete(:observations) if hash[:observations].nil? || hash[:observations] == ""
143
- stringify_keys(hash)
144
- end
145
-
146
- def self.from_hash(hash)
147
- 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)
148
145
  end
149
146
 
150
147
  private
151
148
 
152
- def symbolize_keys(hash)
153
- return hash unless hash.is_a?(Hash)
154
-
155
- hash.each_with_object({}) do |(key, value), result|
156
- new_key = key.to_sym
157
- new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
158
- result[new_key] = new_value
159
- end
160
- end
161
-
162
- def stringify_keys(hash)
163
- return hash unless hash.is_a?(Hash)
164
-
165
- hash.each_with_object({}) do |(key, value), result|
166
- new_key = key.to_s
167
- new_value = value.is_a?(Hash) ? stringify_keys(value) : value
168
- result[new_key] = new_value
169
- 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(", ")
170
155
  end
171
156
  end
172
157
  end