issue-db 1.1.0 → 1.3.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.
@@ -4,218 +4,274 @@ require_relative "cache"
4
4
  require_relative "models/record"
5
5
  require_relative "utils/generate"
6
6
 
7
- class RecordNotFound < StandardError; end
8
-
9
- class Database
10
- include Cache
11
- include Generate
12
-
13
- # :param: log [Logger] a logger object to use for logging
14
- # :param: client [Octokit::Client] an Octokit::Client object to use for interacting with the GitHub API
15
- # :param: repo [Repository] a Repository object that represents the GitHub repository to use as the datastore
16
- # :param: label [String] the label to use for issues managed in the datastore by this library
17
- # :param: cache_expiry [Integer] the number of seconds to cache issues in memory (default: 60)
18
- # :return: A new Database object
19
- def initialize(log, client, repo, label, cache_expiry)
20
- @log = log
21
- @client = client
22
- @repo = repo
23
- @label = label
24
- @cache_expiry = cache_expiry
25
- @issues = nil
26
- @issues_last_updated = nil
27
- end
7
+ module IssueDB
8
+ class RecordNotFound < StandardError; end
9
+
10
+ class Database
11
+ include Cache
12
+ include Generate
13
+
14
+ # :param: log [Logger] a logger object to use for logging
15
+ # :param: client [Octokit::Client] an Octokit::Client object to use for interacting with the GitHub API
16
+ # :param: repo [Repository] a Repository object that represents the GitHub repository to use as the datastore
17
+ # :param: label [String] the label to use for issues managed in the datastore by this library
18
+ # :param: cache_expiry [Integer] the number of seconds to cache issues in memory (default: 60)
19
+ # :return: A new Database object
20
+ def initialize(log, client, repo, label, cache_expiry)
21
+ @log = log
22
+ @client = client
23
+ @repo = repo
24
+ @label = label
25
+ @cache_expiry = cache_expiry
26
+ @issues = nil
27
+ @issues_last_updated = nil
28
+ end
28
29
 
29
- # Create a new issue/record in the database
30
- # This will return the newly created issue as a Record object (parsed)
31
- # :param: key [String] the key (issue title) to create
32
- # :param: data [Hash] the data to use for the issue body
33
- # :param: options [Hash] a hash of options containing extra data such as body_before and body_after
34
- # :return: The newly created issue as a Record object
35
- # usage example:
36
- # data = { color: "blue", cool: true, popularity: 100, tags: ["tag1", "tag2"] }
37
- # options = { body_before: "some text before the data", body_after: "some text after the data", include_closed: true }
38
- # db.create("event123", {cool: true, data: "here"}, options)
39
- def create(key, data, options = {})
40
- @log.debug("attempting to create: #{key}")
41
- issue = find_issue_by_key(key, options, create_mode: true)
42
- if issue
43
- @log.warn("skipping issue creation and returning existing issue - an issue already exists with the key: #{key}")
30
+ # Create a new issue/record in the database
31
+ # This will return the newly created issue as a Record object (parsed)
32
+ # :param: key [String] the key (issue title) to create
33
+ # :param: data [Hash] the data to use for the issue body
34
+ # :param: options [Hash] a hash of options containing extra data such as body_before, body_after, labels, and assignees
35
+ # :return: The newly created issue as a Record object
36
+ # usage example:
37
+ # data = { color: "blue", cool: true, popularity: 100, tags: ["tag1", "tag2"] }
38
+ # options = { body_before: "some text before the data", body_after: "some text after the data", include_closed: true, labels: ["priority:high", "bug"], assignees: ["username1", "username2"] }
39
+ # db.create("event123", {cool: true, data: "here"}, options)
40
+ def create(key, data, options = {})
41
+ @log.debug("attempting to create: #{key}")
42
+ issue = find_issue_by_key(key, options, create_mode: true)
43
+ if issue
44
+ @log.warn("skipping issue creation and returning existing issue - an issue already exists with the key: #{key}")
45
+ return Record.new(issue)
46
+ end
47
+
48
+ # if we make it here, no existing issues were found so we can safely create one
49
+
50
+ body = generate(data, body_before: options[:body_before], body_after: options[:body_after])
51
+
52
+ # Prepare labels array - always include the library-managed label for create operations
53
+ labels = [@label]
54
+ if options[:labels] && options[:labels].is_a?(Array)
55
+ # Add user-provided labels but ensure the library label is not duplicated
56
+ user_labels = options[:labels].reject { |label| label == @label }
57
+ labels.concat(user_labels)
58
+ end
59
+
60
+ # Prepare API options hash
61
+ api_options = { labels: labels }
62
+
63
+ # Add assignees if provided
64
+ if options[:assignees] && options[:assignees].is_a?(Array)
65
+ api_options[:assignees] = options[:assignees]
66
+ end
67
+
68
+ # if we make it here, no existing issues were found so we can safely create one
69
+ issue = @client.create_issue(@repo.full_name, key, body, api_options)
70
+
71
+ # ensure the cache is initialized before appending and handle race conditions
72
+ current_issues = issues
73
+ if current_issues && !current_issues.include?(issue)
74
+ @issues << issue
75
+ end
76
+
77
+ @log.debug("issue created: #{key}")
44
78
  return Record.new(issue)
45
79
  end
46
80
 
47
- # if we make it here, no existing issues were found so we can safely create one
48
-
49
- body = generate(data, body_before: options[:body_before], body_after: options[:body_after])
50
-
51
- # if we make it here, no existing issues were found so we can safely create one
52
- issue = @client.create_issue(@repo.full_name, key, body, { labels: @label })
53
-
54
- # ensure the cache is initialized before appending and handle race conditions
55
- current_issues = issues
56
- if current_issues && !current_issues.include?(issue)
57
- @issues << issue
81
+ # Read an issue/record from the database
82
+ # This will return the issue as a Record object (parsed)
83
+ # :param: key [String] the key (issue title) to read
84
+ # :param: options [Hash] a hash of options to pass through to the search method
85
+ # :return: The issue as a Record object
86
+ def read(key, options = {})
87
+ @log.debug("attempting to read: #{key}")
88
+ issue = find_issue_by_key(key, options)
89
+ @log.debug("issue found: #{key}")
90
+ return Record.new(issue)
58
91
  end
59
92
 
60
- @log.debug("issue created: #{key}")
61
- return Record.new(issue)
62
- end
63
-
64
- # Read an issue/record from the database
65
- # This will return the issue as a Record object (parsed)
66
- # :param: key [String] the key (issue title) to read
67
- # :param: options [Hash] a hash of options to pass through to the search method
68
- # :return: The issue as a Record object
69
- def read(key, options = {})
70
- @log.debug("attempting to read: #{key}")
71
- issue = find_issue_by_key(key, options)
72
- @log.debug("issue found: #{key}")
73
- return Record.new(issue)
74
- end
75
-
76
- # Update an issue/record in the database
77
- # This will return the updated issue as a Record object (parsed)
78
- # :param: key [String] the key (issue title) to update
79
- # :param: data [Hash] the data to use for the issue body
80
- # :param: options [Hash] a hash of options containing extra data such as body_before and body_after
81
- # :return: The updated issue as a Record object
82
- # usage example:
83
- # data = { color: "blue", cool: true, popularity: 100, tags: ["tag1", "tag2"] }
84
- # options = { body_before: "some text before the data", body_after: "some text after the data", include_closed: true }
85
- # db.update("event123", {cool: true, data: "here"}, options)
86
- def update(key, data, options = {})
87
- @log.debug("attempting to update: #{key}")
88
- issue = find_issue_by_key(key, options)
89
-
90
- body = generate(data, body_before: options[:body_before], body_after: options[:body_after])
91
-
92
- updated_issue = @client.update_issue(@repo.full_name, issue.number, key, body)
93
-
94
- # update the issue in the cache using the reference we have
95
- index = @issues.index(issue)
96
- if index
97
- @issues[index] = updated_issue
98
- else
99
- @log.warn("issue not found in cache during update: #{key}")
100
- # Force a cache refresh to ensure consistency
101
- update_issue_cache!
93
+ # Update an issue/record in the database
94
+ # This will return the updated issue as a Record object (parsed)
95
+ # :param: key [String] the key (issue title) to update
96
+ # :param: data [Hash] the data to use for the issue body
97
+ # :param: options [Hash] a hash of options containing extra data such as body_before, body_after, labels, and assignees
98
+ # :return: The updated issue as a Record object
99
+ # usage example:
100
+ # data = { color: "blue", cool: true, popularity: 100, tags: ["tag1", "tag2"] }
101
+ # options = { body_before: "some text before the data", body_after: "some text after the data", include_closed: true, labels: ["priority:high", "bug"], assignees: ["username1", "username2"] }
102
+ # db.update("event123", {cool: true, data: "here"}, options)
103
+ def update(key, data, options = {})
104
+ @log.debug("attempting to update: #{key}")
105
+ issue = find_issue_by_key(key, options)
106
+
107
+ body = generate(data, body_before: options[:body_before], body_after: options[:body_after])
108
+
109
+ # Prepare the API call options
110
+ api_options = {}
111
+
112
+ # Only modify labels if the user explicitly provides them
113
+ if options[:labels] && options[:labels].is_a?(Array)
114
+ # Prepare labels array - always include the library-managed label
115
+ labels = [@label]
116
+ # Add user-provided labels but ensure the library label is not duplicated
117
+ user_labels = options[:labels].reject { |label| label == @label }
118
+ labels.concat(user_labels)
119
+ api_options[:labels] = labels
120
+ end
121
+
122
+ # Only modify assignees if the user explicitly provides them
123
+ if options[:assignees] && options[:assignees].is_a?(Array)
124
+ api_options[:assignees] = options[:assignees]
125
+ end
126
+
127
+ updated_issue = @client.update_issue(@repo.full_name, issue.number, key, body, api_options)
128
+
129
+ # update the issue in the cache using the reference we have
130
+ index = @issues.index(issue)
131
+ if index
132
+ @issues[index] = updated_issue
133
+ else
134
+ @log.warn("issue not found in cache during update: #{key}")
135
+ # Force a cache refresh to ensure consistency
136
+ update_issue_cache!
137
+ end
138
+
139
+ @log.debug("issue updated: #{key}")
140
+ return Record.new(updated_issue)
102
141
  end
103
142
 
104
- @log.debug("issue updated: #{key}")
105
- return Record.new(updated_issue)
106
- end
107
-
108
- # Delete an issue/record from the database - in this context, "delete" means to close the issue as "completed"
109
- # :param: key [String] the key (issue title) to delete
110
- # :param: options [Hash] a hash of options to pass through to the search method
111
- # :return: The deleted issue as a Record object (parsed) - it may contain useful data
112
- def delete(key, options = {})
113
- @log.debug("attempting to delete: #{key}")
114
- issue = find_issue_by_key(key, options)
115
-
116
- deleted_issue = @client.close_issue(@repo.full_name, issue.number)
117
-
118
- # update the issue in the cache using the reference we have
119
- index = @issues.index(issue)
120
- if index
121
- @issues[index] = deleted_issue
122
- else
123
- @log.warn("issue not found in cache during delete: #{key}")
124
- # Force a cache refresh to ensure consistency
125
- update_issue_cache!
143
+ # Delete an issue/record from the database - in this context, "delete" means to close the issue as "completed"
144
+ # :param: key [String] the key (issue title) to delete
145
+ # :param: options [Hash] a hash of options to pass through to the search method and control labels and assignees
146
+ # :return: The deleted issue as a Record object (parsed) - it may contain useful data
147
+ def delete(key, options = {})
148
+ @log.debug("attempting to delete: #{key}")
149
+ issue = find_issue_by_key(key, options)
150
+
151
+ # For delete operations, only update labels and assignees if the user explicitly provides them
152
+ if (options[:labels] && options[:labels].is_a?(Array)) || (options[:assignees] && options[:assignees].is_a?(Array))
153
+ update_options = {}
154
+
155
+ if options[:labels] && options[:labels].is_a?(Array)
156
+ # Prepare labels array - always include the library-managed label
157
+ labels = [@label]
158
+ # Add user-provided labels but ensure the library label is not duplicated
159
+ user_labels = options[:labels].reject { |label| label == @label }
160
+ labels.concat(user_labels)
161
+ update_options[:labels] = labels
162
+ end
163
+
164
+ if options[:assignees] && options[:assignees].is_a?(Array)
165
+ update_options[:assignees] = options[:assignees]
166
+ end
167
+
168
+ # Update the issue with new labels and/or assignees before closing
169
+ @client.update_issue(@repo.full_name, issue.number, update_options)
170
+ end
171
+
172
+ deleted_issue = @client.close_issue(@repo.full_name, issue.number) # update the issue in the cache using the reference we have
173
+ index = @issues.index(issue)
174
+ if index
175
+ @issues[index] = deleted_issue
176
+ else
177
+ @log.warn("issue not found in cache during delete: #{key}")
178
+ # Force a cache refresh to ensure consistency
179
+ update_issue_cache!
180
+ end
181
+
182
+ @log.debug("issue deleted: #{key}")
183
+ # return the deleted issue as a Record object as it may contain useful data
184
+ return Record.new(deleted_issue)
126
185
  end
127
186
 
128
- # return the deleted issue as a Record object as it may contain useful data
129
- return Record.new(deleted_issue)
130
- end
131
-
132
- # List all keys in the database
133
- # This will return an array of strings that represent the issue titles that are "keys" in the database
134
- # :param: options [Hash] a hash of options to pass through to the search method
135
- # :return: An array of strings that represent the issue titles that are "keys" in the database
136
- # usage example:
137
- # options = {include_closed: true}
138
- # keys = db.list_keys(options)
139
- def list_keys(options = {})
140
- current_issues = issues
141
- return [] if current_issues.nil?
142
-
143
- keys = current_issues.select do |issue|
144
- options[:include_closed] || issue[:state] == "open"
145
- end.map do |issue|
146
- issue[:title]
187
+ # List all keys in the database
188
+ # This will return an array of strings that represent the issue titles that are "keys" in the database
189
+ # :param: options [Hash] a hash of options to pass through to the search method
190
+ # :return: An array of strings that represent the issue titles that are "keys" in the database
191
+ # usage example:
192
+ # options = {include_closed: true}
193
+ # keys = db.list_keys(options)
194
+ def list_keys(options = {})
195
+ current_issues = issues
196
+ return [] if current_issues.nil?
197
+
198
+ keys = current_issues.select do |issue|
199
+ options[:include_closed] || issue[:state] == "open"
200
+ end.map do |issue|
201
+ issue[:title]
202
+ end
203
+
204
+ return keys
147
205
  end
148
206
 
149
- return keys
150
- end
151
-
152
- # List all issues/record in the database as Record objects (parsed)
153
- # This will return an array of Record objects that represent the issues in the database
154
- # :param: options [Hash] a hash of options to pass through to the search method
155
- # :return: An array of Record objects that represent the issues in the database
156
- # usage example:
157
- # options = {include_closed: true}
158
- # records = db.list(options)
159
- def list(options = {})
160
- current_issues = issues
161
- return [] if current_issues.nil?
162
-
163
- records = current_issues.select do |issue|
164
- options[:include_closed] || issue[:state] == "open"
165
- end.map do |issue|
166
- Record.new(issue)
207
+ # List all issues/record in the database as Record objects (parsed)
208
+ # This will return an array of Record objects that represent the issues in the database
209
+ # :param: options [Hash] a hash of options to pass through to the search method
210
+ # :return: An array of Record objects that represent the issues in the database
211
+ # usage example:
212
+ # options = {include_closed: true}
213
+ # records = db.list(options)
214
+ def list(options = {})
215
+ current_issues = issues
216
+ return [] if current_issues.nil?
217
+
218
+ records = current_issues.select do |issue|
219
+ options[:include_closed] || issue[:state] == "open"
220
+ end.map do |issue|
221
+ Record.new(issue)
222
+ end
223
+
224
+ return records
167
225
  end
168
226
 
169
- return records
170
- end
171
-
172
- # Force a refresh of the issues cache
173
- # This will update the issues cache with the latest issues from the repo
174
- # :return: The updated issue cache as a list of issues (Hash objects not parsed)
175
- def refresh!
176
- update_issue_cache!
177
- end
227
+ # Force a refresh of the issues cache
228
+ # This will update the issues cache with the latest issues from the repo
229
+ # :return: The updated issue cache as a list of issues (Hash objects not parsed)
230
+ def refresh!
231
+ update_issue_cache!
232
+ end
178
233
 
179
234
  protected
180
235
 
181
- def not_found!(key)
182
- raise RecordNotFound, "no record found for key: #{key}"
183
- end
184
-
185
- # A helper method to search through the issues cache and return the first issue that matches the given key
186
- # :param: key [String] the key (issue title) to search for
187
- # :param: options [Hash] a hash of options to pass through to the search method
188
- # :param: create_mode [Boolean] a flag to indicate whether or not we are in create mode
189
- # :return: A direct reference to the issue as a Hash object if found, otherwise throws a RecordNotFound error
190
- # ... unless create_mode is true, in which case it returns nil as a signal to proceed with creating the issue
191
- def find_issue_by_key(key, options = {}, create_mode: false)
192
- issue = issues.find do |issue|
193
- issue[:title] == key && (options[:include_closed] || issue[:state] == "open")
236
+ def not_found!(key)
237
+ raise RecordNotFound, "no record found for key: #{key}"
194
238
  end
195
239
 
196
- if issue.nil?
197
- @log.debug("no issue found in cache for: #{key}")
198
- return nil if create_mode
199
-
200
- not_found!(key)
240
+ # A helper method to search through the issues cache and return the first issue that matches the given key
241
+ # :param: key [String] the key (issue title) to search for
242
+ # :param: options [Hash] a hash of options to pass through to the search method
243
+ # :param: create_mode [Boolean] a flag to indicate whether or not we are in create mode
244
+ # :return: A direct reference to the issue as a Hash object if found, otherwise throws a RecordNotFound error
245
+ # ... unless create_mode is true, in which case it returns nil as a signal to proceed with creating the issue
246
+ def find_issue_by_key(key, options = {}, create_mode: false)
247
+ issue = issues.find do |issue|
248
+ issue[:title] == key && (options[:include_closed] || issue[:state] == "open")
249
+ end
250
+
251
+ if issue.nil?
252
+ @log.debug("no issue found in cache for: #{key}")
253
+ return nil if create_mode
254
+
255
+ not_found!(key)
256
+ end
257
+
258
+ @log.debug("issue found in cache for: #{key}")
259
+ return issue
201
260
  end
202
261
 
203
- @log.debug("issue found in cache for: #{key}")
204
- return issue
205
- end
262
+ # A helper method to fetch all issues from the repo and update the issues cache
263
+ # It is cache aware
264
+ def issues
265
+ # update the issues cache if it is nil
266
+ update_issue_cache! if @issues.nil?
206
267
 
207
- # A helper method to fetch all issues from the repo and update the issues cache
208
- # It is cache aware
209
- def issues
210
- # update the issues cache if it is nil
211
- update_issue_cache! if @issues.nil?
268
+ # update the cache if it has expired (with nil safety)
269
+ if !@issues_last_updated.nil? && (Time.now - @issues_last_updated) > @cache_expiry
270
+ @log.debug("issue cache expired - last updated: #{@issues_last_updated} - refreshing now")
271
+ update_issue_cache!
272
+ end
212
273
 
213
- # update the cache if it has expired (with nil safety)
214
- if !@issues_last_updated.nil? && (Time.now - @issues_last_updated) > @cache_expiry
215
- @log.debug("issue cache expired - last updated: #{@issues_last_updated} - refreshing now")
216
- update_issue_cache!
274
+ return @issues
217
275
  end
218
-
219
- return @issues
220
276
  end
221
277
  end
@@ -2,34 +2,36 @@
2
2
 
3
3
  require_relative "../utils/parse"
4
4
 
5
- class IssueParseError < StandardError; end
5
+ module IssueDB
6
+ class IssueParseError < StandardError; end
6
7
 
7
- class Record
8
- include Parse
8
+ class Record
9
+ include Parse
9
10
 
10
- attr_reader :body_before, :data, :body_after, :source_data, :key
11
- def initialize(data)
12
- @key = data.title
13
- @source_data = data
14
- parse!
15
- end
11
+ attr_reader :body_before, :data, :body_after, :source_data, :key
12
+ def initialize(data)
13
+ @key = data.title
14
+ @source_data = data
15
+ parse!
16
+ end
16
17
 
17
18
  protected
18
19
 
19
- def parse!
20
- if @source_data.body.nil? || @source_data.body.strip == ""
21
- raise IssueParseError, "issue body is empty for issue number #{@source_data.number}"
20
+ def parse!
21
+ if @source_data.body.nil? || @source_data.body.strip == ""
22
+ raise IssueParseError, "issue body is empty for issue number #{@source_data.number}"
23
+ end
24
+
25
+ begin
26
+ parsed = parse(@source_data.body)
27
+ rescue JSON::ParserError => e
28
+ message = "failed to parse issue body data contents for issue number: #{@source_data.number} - #{e.message}"
29
+ raise IssueParseError, message
30
+ end
31
+
32
+ @body_before = parsed[:body_before]
33
+ @data = parsed[:data]
34
+ @body_after = parsed[:body_after]
22
35
  end
23
-
24
- begin
25
- parsed = parse(@source_data.body)
26
- rescue JSON::ParserError => e
27
- message = "failed to parse issue body data contents for issue number: #{@source_data.number} - #{e.message}"
28
- raise IssueParseError, message
29
- end
30
-
31
- @body_before = parsed[:body_before]
32
- @data = parsed[:data]
33
- @body_after = parsed[:body_after]
34
36
  end
35
37
  end
@@ -1,22 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class RepoFormatError < StandardError; end
3
+ module IssueDB
4
+ class RepoFormatError < StandardError; end
4
5
 
5
- class Repository
6
- attr_reader :owner, :repo, :full_name
7
- def initialize(repo)
8
- @repo = repo
9
- validate!
10
- end
6
+ class Repository
7
+ attr_reader :owner, :repo, :full_name
8
+ def initialize(repo)
9
+ @repo = repo
10
+ validate!
11
+ end
11
12
 
12
13
  protected
13
14
 
14
- def validate!
15
- if @repo.nil? || !@repo.include?("/")
16
- raise RepoFormatError, "repository #{@repo} is invalid - valid format: <owner>/<repo>"
17
- end
15
+ def validate!
16
+ if @repo.nil? || !@repo.include?("/")
17
+ raise RepoFormatError, "repository #{@repo} is invalid - valid format: <owner>/<repo>"
18
+ end
18
19
 
19
- @full_name = @repo.strip
20
- @owner, @repo = @full_name.split("/")
20
+ @full_name = @repo.strip
21
+ @owner, @repo = @full_name.split("/")
22
+ end
21
23
  end
22
24
  end