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.
@@ -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
@@ -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,48 +1,33 @@
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
- }
38
- end
39
-
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)
30
+ self.comments = (comments || []) + [comment.is_a?(Comment) ? comment : Comment.from_hash(comment)]
46
31
  end
47
32
 
48
33
  def schema_name
@@ -50,40 +35,15 @@ module Commenter
50
35
  end
51
36
 
52
37
  def to_yaml_document(schema_dir = "schema")
53
- "# yaml-language-server: $schema=#{File.join(schema_dir.to_s, schema_name)}\n\n#{to_yaml_h.to_yaml}"
38
+ "# yaml-language-server: $schema=#{File.join(schema_dir.to_s, schema_name)}\n\n#{to_yaml}"
54
39
  end
55
40
 
56
- def self.from_hash(hash)
57
- new(hash)
58
- end
59
-
60
- private
61
-
62
- def symbolize_keys(hash)
63
- return hash unless hash.is_a?(Hash)
64
-
65
- hash.each_with_object({}) do |(key, value), result|
66
- new_key = key.to_sym
67
- new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
68
- result[new_key] = new_value
69
- end
41
+ def to_yaml_h
42
+ CommentSheet.to_hash(self)
70
43
  end
71
44
 
72
- def stringify_keys(hash)
73
- return hash unless hash.is_a?(Hash)
74
-
75
- hash.each_with_object({}) do |(key, value), result|
76
- new_key = key.to_s
77
- new_value = case value
78
- when Hash
79
- stringify_keys(value)
80
- when Array
81
- value.map { |item| item.is_a?(Hash) ? stringify_keys(item) : item }
82
- else
83
- value
84
- end
85
- result[new_key] = new_value
86
- end
45
+ def to_h
46
+ CommentSheet.to_hash(self)
87
47
  end
88
48
  end
89
49
  end
@@ -25,7 +25,11 @@ module Commenter
25
25
  end
26
26
 
27
27
  def full_name(type)
28
- FULL_NAMES.fetch(type.to_s.strip.downcase) { type&.to_s&.strip }
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)
29
33
  end
30
34
 
31
35
  def display_name(type)
@@ -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
@@ -1,9 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "docx"
4
+ require "zip"
4
5
 
5
6
  module Commenter
6
7
  class Filler
8
+ # Labels of the sheet metadata fields in the template's page header
9
+ # (word/header*.xml). Values supplied to #fill are appended after each
10
+ # label; the docx gem cannot write header parts (see docx PR #73), so
11
+ # this is done by rewriting the entry directly.
12
+ HEADER_METADATA = { date: "Date", document: "Document", project: "Project" }.freeze
13
+
7
14
  def fill(template_path, output_path, comments, options = {})
8
15
  doc = Docx::Document.open(template_path)
9
16
  table = doc.tables.first
@@ -11,48 +18,88 @@ module Commenter
11
18
  raise "No table found in template" unless table
12
19
  raise "Template table must have at least one row" if table.row_count < 1
13
20
 
14
- # Get the template row (first row in the table)
15
- template_row = table.rows.first
16
-
17
- # Add new rows for each comment by copying the template row
18
- comments.each_with_index do |comment, _index|
19
- # Convert comment to symbol keys for consistent access
20
- comment_data = symbolize_keys(comment)
21
-
22
- # Copy the template row and insert it
23
- begin
24
- new_row = template_row.copy
25
- new_row.insert_before(template_row)
26
- row = new_row
27
- rescue StandardError => e
28
- puts "Warning: Could not add row for comment #{comment_data[:id]}: #{e.message}"
29
- next
30
- end
21
+ # The template row (first row in the table) is copied for each comment;
22
+ # all original template rows are removed afterwards via their XML
23
+ # nodes (the shipped 2012-03 template carries extra blank rows beyond
24
+ # the first, and the docx gem's Row exposes no #remove).
25
+ template_rows = table.rows.to_a
26
+ comments.each { |comment| fill_row(template_rows.first, comment, options) }
27
+ template_rows.each { |row| row.node.remove }
28
+
29
+ doc.save(output_path)
30
+ write_header_metadata(output_path, options)
31
+ output_path
32
+ end
33
+
34
+ private
35
+
36
+ def fill_row(template_row, comment, options)
37
+ comment = Comment.from_hash(comment) unless comment.is_a?(Comment)
38
+
39
+ new_row = template_row.copy
40
+ new_row.insert_before(template_row)
41
+
42
+ set_cell_text(new_row.cells[0], comment.id.to_s)
43
+ set_cell_text(new_row.cells[1], comment.line_number.to_s)
44
+ set_cell_text(new_row.cells[2], comment.clause.to_s)
45
+ set_cell_text(new_row.cells[3], comment.element.to_s)
46
+ set_cell_text(new_row.cells[4], comment.type.to_s)
47
+ set_cell_text(new_row.cells[5], comment.comments.to_s)
48
+ set_cell_text(new_row.cells[6], comment.proposed_change.to_s)
49
+
50
+ observations = comment.observations.to_s
51
+ return if observations.empty?
52
+
53
+ set_cell_text(new_row.cells[7], observations)
54
+ apply_shading(new_row.cells[7], observations) if options[:shading]
55
+ rescue StandardError => e
56
+ puts "Warning: Could not add row for comment #{comment.id}: #{e.message}"
57
+ end
58
+
59
+ def write_header_metadata(output_path, options)
60
+ metadata = HEADER_METADATA.map do |key, label|
61
+ value = options[key].to_s.strip
62
+ value.empty? ? nil : [label, value]
63
+ end.compact
64
+ return if metadata.empty?
31
65
 
32
- # Map comment to table cells using text substitution
33
- set_cell_text(row.cells[0], comment_data[:id] || "")
34
- set_cell_text(row.cells[1], comment_data.dig(:locality, :line_number) || "")
35
- set_cell_text(row.cells[2], comment_data.dig(:locality, :clause) || "")
36
- set_cell_text(row.cells[3], comment_data.dig(:locality, :element) || "")
37
- set_cell_text(row.cells[4], comment_data[:type] || "")
38
- set_cell_text(row.cells[5], comment_data[:comments] || "")
39
- set_cell_text(row.cells[6], comment_data[:proposed_change] || "")
40
-
41
- # Handle observations with optional shading
42
- observations = comment_data[:observations]
43
- if observations && !observations.empty?
44
- set_cell_text(row.cells[7], observations)
45
- apply_shading(row.cells[7], observations) if options[:shading]
66
+ rewrite_headers(output_path, metadata)
67
+ end
68
+
69
+ def rewrite_headers(output_path, metadata)
70
+ # In-memory buffer + File.binwrite rather than rewriting the archive
71
+ # in place: rubyzip commits in-place edits with File.rename, which
72
+ # fails with EACCES on Windows.
73
+ buffer = Zip::OutputStream.write_buffer do |out|
74
+ Zip::File.open(output_path) do |zip|
75
+ zip.entries.each do |entry|
76
+ # Block form: the entry stream is closed eagerly, otherwise its
77
+ # handle keeps the file locked on Windows and the binwrite below
78
+ # fails with EACCES.
79
+ content = entry.get_input_stream(&:read)
80
+ out.put_next_entry(entry.name)
81
+ out.write(patch_header(entry.name, content, metadata))
82
+ end
46
83
  end
47
84
  end
85
+ File.binwrite(output_path, buffer.string)
86
+ end
48
87
 
49
- # Remove the original template row after all comments are added
50
- template_row.remove if template_row.respond_to?(:remove)
88
+ def patch_header(entry_name, content, metadata)
89
+ return content unless entry_name.match?(%r{\Aword/header\d*\.xml\z})
51
90
 
52
- doc.save(output_path)
91
+ xml = content.dup.force_encoding(Encoding::UTF_8)
92
+ metadata.each do |label, value|
93
+ xml.sub!(%r{(<w:t[^>]*>)\s*#{label}:\s*(</w:t>)}) do
94
+ "#{Regexp.last_match(1)}#{label}: #{escape(value)}#{Regexp.last_match(2)}"
95
+ end
96
+ end
97
+ xml
53
98
  end
54
99
 
55
- private
100
+ def escape(text)
101
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
102
+ end
56
103
 
57
104
  def set_cell_text(cell, text)
58
105
  return if text.nil? || text.empty?
@@ -86,14 +133,16 @@ module Commenter
86
133
  puts "Warning: Could not set text '#{text}' in cell: #{e.message}"
87
134
  end
88
135
 
89
- def symbolize_keys(hash)
90
- return hash unless hash.is_a?(Hash)
136
+ SHADING_COLORS = {
137
+ accept_with_modifications: "C4D79B", # Olive Green
138
+ accepted: "92D050", # Green
139
+ noted: "8DB4E2", # Blue
140
+ rejected: "FF99CC", # Pink
141
+ todo: "D9D9D9" # Light Gray (for diagonal stripes, we use solid for now)
142
+ }.freeze
91
143
 
92
- hash.each_with_object({}) do |(key, value), result|
93
- new_key = key.to_sym
94
- new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
95
- result[new_key] = new_value
96
- end
144
+ def determine_shading_color(observation)
145
+ SHADING_COLORS[DispositionStatus.match(observation)]
97
146
  end
98
147
 
99
148
  def apply_shading(cell, observation)
@@ -111,23 +160,6 @@ module Commenter
111
160
  puts "Warning: Could not apply shading to cell: #{e.message}"
112
161
  end
113
162
 
114
- def determine_shading_color(observation)
115
- text = observation.downcase.strip
116
-
117
- case text
118
- when /awm|accept with modifications/
119
- "C4D79B" # Olive Green
120
- when /accept(ed)?/
121
- "92D050" # Green
122
- when /noted/
123
- "8DB4E2" # Blue
124
- when /reject(ed)?/
125
- "FF99CC" # Pink
126
- when /todo/
127
- "D9D9D9" # Light Gray (for diagonal stripes, we'll use solid for now)
128
- end
129
- end
130
-
131
163
  def apply_cell_shading(cell, color)
132
164
  # Access the cell's XML node
133
165
  cell_node = cell.node
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Commenter
6
+ # GitHub issue tracking state attached to a comment by github-create /
7
+ # github-retrieve.
8
+ class GithubInfo < Lutaml::Model::Serializable
9
+ attribute :issue_number, :integer
10
+ attribute :issue_url, :string
11
+ attribute :status, :string
12
+ attribute :created_at, :string
13
+ attribute :updated_at, :string
14
+
15
+ yaml do
16
+ map "issue_number", to: :issue_number
17
+ map "issue_url", to: :issue_url
18
+ map "status", to: :status
19
+ map "created_at", to: :created_at
20
+ map "updated_at", to: :updated_at
21
+ end
22
+ end
23
+ end