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.
- checksums.yaml +4 -4
- data/README.md +230 -6
- data/issue-db.gemspec +1 -1
- data/lib/issue_db/authentication.rb +36 -28
- data/lib/issue_db/cache.rb +27 -25
- data/lib/issue_db/database.rb +245 -189
- data/lib/issue_db/models/record.rb +25 -23
- data/lib/issue_db/models/repository.rb +15 -13
- data/lib/issue_db/utils/generate.rb +38 -36
- data/lib/issue_db/utils/github.rb +282 -280
- data/lib/issue_db/utils/init.rb +19 -17
- data/lib/issue_db/utils/parse.rb +33 -31
- data/lib/issue_db.rb +59 -47
- data/lib/version.rb +4 -2
- metadata +1 -1
data/lib/issue_db/database.rb
CHANGED
@@ -4,218 +4,274 @@ require_relative "cache"
|
|
4
4
|
require_relative "models/record"
|
5
5
|
require_relative "utils/generate"
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
-
#
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
#
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
61
|
-
return Record
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
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
|
-
#
|
129
|
-
return
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
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
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
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
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
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
|
-
|
182
|
-
|
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
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
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
|
-
|
204
|
-
|
205
|
-
|
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
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
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
|
-
|
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
|
-
|
5
|
+
module IssueDB
|
6
|
+
class IssueParseError < StandardError; end
|
6
7
|
|
7
|
-
class Record
|
8
|
-
|
8
|
+
class Record
|
9
|
+
include Parse
|
9
10
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
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
|
-
|
20
|
-
|
21
|
-
|
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
|
-
|
3
|
+
module IssueDB
|
4
|
+
class RepoFormatError < StandardError; end
|
4
5
|
|
5
|
-
class Repository
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
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
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
20
|
-
|
20
|
+
@full_name = @repo.strip
|
21
|
+
@owner, @repo = @full_name.split("/")
|
22
|
+
end
|
21
23
|
end
|
22
24
|
end
|