jira_cache 0.2.1

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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +10 -0
  3. data/.env.example +10 -0
  4. data/.env.test +10 -0
  5. data/.gitignore +15 -0
  6. data/.rspec +2 -0
  7. data/.rubocop.yml +12 -0
  8. data/.ruby-gemset +1 -0
  9. data/.ruby-version +1 -0
  10. data/.travis.yml +15 -0
  11. data/CODE_OF_CONDUCT.md +13 -0
  12. data/Gemfile +21 -0
  13. data/Guardfile +1 -0
  14. data/HISTORY.md +22 -0
  15. data/LICENSE.txt +22 -0
  16. data/README.md +73 -0
  17. data/Rakefile +7 -0
  18. data/VERSION +1 -0
  19. data/bin/console +11 -0
  20. data/bin/db/migrate +7 -0
  21. data/bin/db/psql +7 -0
  22. data/bin/db/reset +7 -0
  23. data/bin/setup +7 -0
  24. data/bin/sync +14 -0
  25. data/config.ru +7 -0
  26. data/config/Guardfile +35 -0
  27. data/config/boot.rb +11 -0
  28. data/config/db_migrations/001_create_issues.rb +21 -0
  29. data/docker-compose.yml +25 -0
  30. data/jira_cache.gemspec +41 -0
  31. data/lib/jira_cache.rb +53 -0
  32. data/lib/jira_cache/client.rb +185 -0
  33. data/lib/jira_cache/data.rb +10 -0
  34. data/lib/jira_cache/data/issue_repository.rb +94 -0
  35. data/lib/jira_cache/notifier.rb +30 -0
  36. data/lib/jira_cache/sync.rb +110 -0
  37. data/lib/jira_cache/version.rb +3 -0
  38. data/lib/jira_cache/webhook_app.rb +55 -0
  39. data/spec/fixtures/responses/get_issue_keys_jql_query_project=/"multiple_requests/"_start_at_0.json +1 -0
  40. data/spec/fixtures/responses/get_issue_keys_jql_query_project=/"multiple_requests/"_start_at_10.json +1 -0
  41. data/spec/fixtures/responses/get_issue_keys_jql_query_project=/"multiple_requests/"_start_at_5.json +1 -0
  42. data/spec/fixtures/responses/get_issue_keys_jql_query_project=/"single_request/"_start_at_0.json +1 -0
  43. data/spec/fixtures/responses/get_issue_many_worklogs.json +1 -0
  44. data/spec/fixtures/responses/get_issue_not_found.json +1 -0
  45. data/spec/fixtures/responses/get_issue_simple.json +1 -0
  46. data/spec/fixtures/responses/get_issue_worklog_many_worklogs.json +1 -0
  47. data/spec/spec_helper.rb +47 -0
  48. data/spec/support/response_fixture.rb +16 -0
  49. data/spec/unit/client_spec.rb +130 -0
  50. data/spec/unit/data/issue_repository_spec.rb +58 -0
  51. data/spec/unit/notifier_spec.rb +18 -0
  52. data/spec/unit/sync_spec.rb +116 -0
  53. data/spec/unit/webhook_app_spec.rb +96 -0
  54. metadata +280 -0
@@ -0,0 +1,41 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+ lib = File.expand_path("../lib", __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "jira_cache/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "jira_cache"
9
+ spec.version = JiraCache::VERSION
10
+ spec.authors = ["Romain Champourlier"]
11
+ spec.email = ["pro@rchampourlier.com"]
12
+ spec.summary = "Fetches data from JIRA and caches it in a local database."
13
+ spec.homepage = "https://github.com/rchampourlier/jira_cache"
14
+ spec.license = "MIT"
15
+
16
+ # Prevent pushing this gem to RubyGems.org by setting "allowed_push_host", or
17
+ # delete this section to allow pushing this gem to any host.
18
+ unless spec.respond_to?(:metadata)
19
+ raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
20
+ end
21
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
22
+
23
+ spec.files = `git ls-files -z`.split("\x0")
24
+ spec.executables = []
25
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_dependency "i18n"
29
+ spec.add_dependency "activesupport-inflector"
30
+ spec.add_dependency "pg"
31
+ spec.add_dependency "sequel"
32
+ spec.add_dependency "sequel_pg"
33
+ spec.add_dependency "rest-client"
34
+ spec.add_dependency "sinatra"
35
+
36
+ spec.add_development_dependency "bundler", "~> 1.7"
37
+ spec.add_development_dependency "rake", "~> 10.0"
38
+ spec.add_development_dependency "pry"
39
+ spec.add_development_dependency "awesome_print"
40
+ spec.add_development_dependency "dotenv"
41
+ end
data/lib/jira_cache.rb ADDED
@@ -0,0 +1,53 @@
1
+ require "jira_cache/version"
2
+ require "jira_cache/sync"
3
+ require "jira_cache/webhook_app"
4
+
5
+ # JiraCache enables storing JIRA issues fetched from the API
6
+ # in a local storage for easier and faster processing.
7
+ #
8
+ # This is the main module and it provides some high level
9
+ # methods to either trigger a full project sync, a single
10
+ # issue sync or start a Sinatra webhook app to trigger sync
11
+ # on JIRA"s webhooks.
12
+ module JiraCache
13
+
14
+ # Sync issues using the specified client. If a `project_key` is
15
+ # specified, only syncs the issues for the corresponding project.
16
+ def self.sync_issues(client: default_client, project_key: nil)
17
+ Sync.new(client).sync_issues(project_key: project_key)
18
+ end
19
+
20
+ def self.sync_issue(issue_key, client: default_client)
21
+ Sync.new(client).sync_issue(issue_key)
22
+ end
23
+
24
+ # @param client [JiraCache::Client]: defaults to a default
25
+ # client using environment variables for domain, username
26
+ # and password, a logger writing to STDOUT and a default
27
+ # `JiraCache::Notifier` instance as notifier.
28
+ def self.webhook_app(client: default_client)
29
+ Sinatra.new(JiraCache::WebhookApp) do
30
+ set(:client, client)
31
+ end
32
+ end
33
+
34
+ def self.default_client
35
+ JiraCache::Client.new(
36
+ domain: ENV["JIRA_DOMAIN"],
37
+ username: ENV["JIRA_USERNAME"],
38
+ password: ENV["JIRA_PASSWORD"],
39
+ logger: default_logger,
40
+ notifier: default_notifier
41
+ )
42
+ end
43
+
44
+ def self.default_logger
45
+ logger = Logger.new(STDOUT)
46
+ logger.level = Logger::DEBUG
47
+ logger
48
+ end
49
+
50
+ def self.default_notifier(logger: default_logger)
51
+ JiraCache::Notifier.new(logger)
52
+ end
53
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+ require "rest-client"
3
+ require "base64"
4
+ require "jira_cache/notifier"
5
+
6
+ module JiraCache
7
+
8
+ # The JIRA API Client.
9
+ class Client
10
+ JIRA_MAX_RESULTS = 1000
11
+
12
+ EXPANDED_FIELDS = %w(
13
+ renderedFields
14
+ changelog
15
+ ).freeze
16
+ # Other possible fields: names, schema, operations, editmeta
17
+
18
+ attr_reader :logger, :notifier
19
+
20
+ # Returns a new instance of the client, configured with
21
+ # the specified parameters.
22
+ #
23
+ # @param domain [String] JIRA API domain (e.g. your-project.atlassian.net)
24
+ # @param username [String] JIRA user"s name, if required
25
+ # @param password [String] JIRA user"s password, if required
26
+ # @param logger [Logger] used to log message (defaults to a logger to STDOUT at
27
+ # info level)
28
+ # @param notifier [Notifier] a notifier instance that will be used to publish
29
+ # event notifications (see `JiraCache::Notifier` for more information)
30
+ #
31
+ def initialize(domain: ENV["JIRA_DOMAIN"],
32
+ username: ENV["JIRA_USERNAME"],
33
+ password: ENV["JIRA_PASSWORD"],
34
+ notifier: default_notifier,
35
+ logger: default_logger)
36
+ check_domain!(domain)
37
+ check_password!(username, password)
38
+ @domain = domain
39
+ @username = username
40
+ @password = password
41
+ @notifier = notifier
42
+ @logger = logger
43
+ end
44
+
45
+ # Fetches the issue represented by id_or_key from the
46
+ # client.
47
+ # If the data is already present in the cache,
48
+ # returns the cached version, unless if :allow_cache
49
+ # option is false.
50
+ def issue_data(id_or_key)
51
+ logger.info "Fetching data for issue #{id_or_key}"
52
+ issue_data = do_get("/issue/#{id_or_key}",
53
+ expand: EXPANDED_FIELDS.join(",")
54
+ ).to_hash
55
+ return nil if issue_not_found?(issue_data)
56
+ issue_data = complete_worklogs(id_or_key, issue_data)
57
+ begin
58
+ notifier.publish "fetched_issue", key: id_or_key, data: issue_data
59
+ rescue => e
60
+ logger.error "Notifier failed: #{e}"
61
+ logger.error e.backtrace
62
+ end
63
+ issue_data
64
+ end
65
+
66
+ def issue_keys_for_query(jql_query)
67
+ start_at = 0
68
+ issues = []
69
+ loop do
70
+ total, page_issues = issue_ids_in_limits(jql_query, start_at)
71
+ logger.info "Total number of issues: #{total}" if issues.length == 0
72
+ issues += page_issues
73
+ logger.info " -- loaded #{page_issues.length} issues"
74
+ start_at = issues.length
75
+ break if issues.length == total
76
+ end
77
+ issues.collect { |issue| issue["key"] }
78
+ end
79
+
80
+ # Implementation methods
81
+ # ======================
82
+
83
+ # @return [total, issues]
84
+ # - total: [Int] the total number of issues in the query results
85
+ # - issues: [Array] array of issues in the response
86
+ # (max `JIRA_MAX_RESULTS`)
87
+ def issue_ids_in_limits(jql_query, start_at)
88
+ results = do_get "/search",
89
+ jql: jql_query,
90
+ startAt: start_at,
91
+ fields: "id",
92
+ maxResults: JIRA_MAX_RESULTS
93
+ [results["total"], results["issues"]]
94
+ end
95
+
96
+ def issue_not_found?(issue_data)
97
+ return false if issue_data["errorMessages"].nil?
98
+ issue_data["errorMessages"].first == "Issue Does Not Exist"
99
+ end
100
+
101
+ def complete_worklogs(id_or_key, issue_data)
102
+ if incomplete_worklogs?(issue_data)
103
+ issue_data["fields"]["worklog"] = issue_worklog_content(id_or_key)
104
+ end
105
+ issue_data
106
+ end
107
+
108
+ def incomplete_worklogs?(issue_data)
109
+ worklog = issue_data["fields"]["worklog"]
110
+ worklog["total"].to_i > worklog["maxResults"].to_i
111
+ end
112
+
113
+ def issue_worklog_content(id_or_key)
114
+ do_get("/issue/#{id_or_key}/worklog").to_hash
115
+ end
116
+
117
+ def project_data(id)
118
+ do_get "/project/#{id}"
119
+ end
120
+
121
+ def projects_data
122
+ do_get "/project"
123
+ end
124
+
125
+ def do_get(path, params = {})
126
+ logger.debug "GET #{uri(path)} #{params}"
127
+ response = RestClient.get uri(path),
128
+ params: params,
129
+ content_type: "application/json"
130
+ begin
131
+ JSON.parse(response.body)
132
+ rescue JSON::ParseError
133
+ response.body
134
+ end
135
+ end
136
+
137
+ # Returns the JIRA API"s base URI (build using `config[:domain]`)
138
+ def uri(path)
139
+ "https://#{authorization_prefix}#{@domain}/rest/api/2#{path}"
140
+ end
141
+
142
+ def authorization_prefix
143
+ return "" if missing_credential?
144
+ "#{CGI.escape(@username)}:#{CGI.escape(@password)}@"
145
+ end
146
+
147
+ def default_logger
148
+ return @logger unless @logger.nil?
149
+ @logger = ::Logger.new(STDOUT)
150
+ @logger.level = ::Logger::FATAL
151
+ @logger
152
+ end
153
+
154
+ def default_notifier
155
+ return @notifier unless @notifier.nil?
156
+ @notifier = JiraCache::Notifier.new(@logger)
157
+ end
158
+
159
+ # Returns an hash of info on the client
160
+ def info
161
+ {
162
+ domain: @domain,
163
+ username: @username
164
+ }
165
+ end
166
+
167
+ private
168
+
169
+ def check_domain!(domain)
170
+ raise "Missing domain" if domain.nil? || domain.empty?
171
+ end
172
+
173
+ def check_password!(username, password)
174
+ unless (username.nil? || username.empty?)
175
+ raise "Missing password (mandatory if username given)" if password.nil? || password.empty?
176
+ end
177
+ end
178
+
179
+ def missing_credential?
180
+ return true if @username.nil? || @username.empty?
181
+ return true if @password.nil? || @password.empty?
182
+ false
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ require "sequel"
3
+
4
+ module JiraCache
5
+ module Data
6
+ DATABASE_URL = ENV["DATABASE_URL"]
7
+ DB = Sequel.connect(DATABASE_URL)
8
+ DB.extension :pg_array, :pg_json
9
+ end
10
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ require "jira_cache/data"
3
+ require "active_support/inflector"
4
+
5
+ module JiraCache
6
+ module Data
7
+
8
+ # Superclass for repositories. Simply provide some shared
9
+ # methods.
10
+ class IssueRepository
11
+
12
+ # It inserts a new issue row with the specified data.
13
+ # If the issue already exists (checking on the "key"),
14
+ # the row is updated instead.
15
+ def self.insert(key:, data:, synced_at:, deleted_from_jira_at: nil)
16
+ attributes = {
17
+ key: key,
18
+ data: Sequel.pg_json(data),
19
+ synced_at: synced_at,
20
+ deleted_from_jira_at: deleted_from_jira_at
21
+ }
22
+ if exist_with_key?(key)
23
+ update_where({ key: key }, attributes)
24
+ else
25
+ table.insert row(attributes)
26
+ end
27
+ end
28
+
29
+ def self.find_by_key(key)
30
+ table.where(key: key).first
31
+ end
32
+
33
+ def self.exist_with_key?(key)
34
+ table.where(key: key).count != 0
35
+ end
36
+
37
+ def self.keys_in_project(project_key)
38
+ table.where("(data #>> '{fields,project,key}') = ?", project_key).select(:key).map(&:values).flatten
39
+ end
40
+
41
+ def self.keys_for_non_deleted_issues
42
+ table
43
+ .where("deleted_from_jira_at IS NULL")
44
+ .select(:key)
45
+ .map(&:values)
46
+ .flatten
47
+ end
48
+
49
+ def self.keys_for_deleted_issues
50
+ table
51
+ .where("deleted_from_jira_at IS NOT NULL")
52
+ .select(:key)
53
+ .map(&:values)
54
+ .flatten
55
+ end
56
+
57
+ def self.delete_where(where_data)
58
+ table.where(where_data).delete
59
+ end
60
+
61
+ def self.update_where(where_data, values)
62
+ table.where(where_data).update(values)
63
+ end
64
+
65
+ def self.first_where(where_data)
66
+ table.where(where_data).first
67
+ end
68
+
69
+ def self.index
70
+ table.entries
71
+ end
72
+
73
+ def self.count
74
+ table.count
75
+ end
76
+
77
+ def self.latest_sync_time
78
+ table.order(:synced_at).select(:synced_at).last&.dig(:synced_at)
79
+ end
80
+
81
+ def self.row(attributes, time = nil)
82
+ time ||= Time.now
83
+ attributes.merge(
84
+ created_at: time,
85
+ updated_at: time
86
+ )
87
+ end
88
+
89
+ def self.table
90
+ DB[:jira_cache_issues]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,30 @@
1
+ module JiraCache
2
+
3
+ # This notifiers simply logs messages using the specified
4
+ # logger.
5
+ #
6
+ # If you want to use this mechanism to trigger actions when
7
+ # events are triggered in JiraCache, you can use the
8
+ # `JiraCache::Client.set_notifier(notifier)` method and pass
9
+ # it an instance of a notifier class implementing the
10
+ # `#publish` method with the same signature as
11
+ # `JiraCache::Notifier#publish`.
12
+ class Notifier
13
+
14
+ # Initializes a notifier with the specified logger. The
15
+ # logger is used to log info messages when #publish
16
+ # is called.
17
+ def initialize(logger)
18
+ @logger = logger
19
+ end
20
+
21
+ # Simply logs the event name and data.
22
+ # @param event_name [String] e.g. "fetched_issue"
23
+ # @param data [Hash]
24
+ # - :key [String] issue key
25
+ # - :data [Hash] issue data
26
+ def publish(event_name, data = nil)
27
+ @logger.info "[#{event_name}] #{data[:key]}"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+ require "jira_cache/data/issue_repository"
3
+ require "jira_cache/client"
4
+
5
+ module JiraCache
6
+
7
+ # Performs the sync between JIRA and the local database
8
+ # where the issues are cached.
9
+ #
10
+ # The issues are cached in the database through the
11
+ # Data::IssueRepository interface. It currently implements
12
+ # storage into a PostgreSQL database.
13
+ class Sync
14
+ attr_reader :client, :logger
15
+
16
+ def initialize(client)
17
+ @client = client
18
+ @logger = client.logger
19
+ end
20
+
21
+ # Fetches new and updated raw issues, save them
22
+ # to the `issues` collection. Also mark issues
23
+ # deleted from JIRA as such.
24
+ #
25
+ # @param project_key [String] the JIRA project key
26
+ def sync_issues(project_key: nil)
27
+ sync_start = Time.now
28
+
29
+ log "Determining which issues to fetch..."
30
+ remote = remote_keys(project_key: project_key)
31
+ log " - #{remote.count} remote issues"
32
+
33
+ cached = cached_keys(project_key: project_key)
34
+ log " - #{cached.count} cached issues"
35
+
36
+ missing = remote - cached
37
+ log " => #{missing.count} missing issues"
38
+
39
+ updated = updated_keys(project_key: project_key)
40
+ log " - #{updated.count} updated issues"
41
+
42
+ log "Fetching #{missing.count + updated.count} issues"
43
+ fetch_issues(missing + updated, sync_start)
44
+
45
+ deleted = cached - remote
46
+ mark_deleted(deleted)
47
+ end
48
+
49
+ def sync_issue(key, sync_time: Time.now)
50
+ data = client.issue_data(key)
51
+ Data::IssueRepository.insert(
52
+ key: key,
53
+ data: data,
54
+ synced_at: sync_time
55
+ )
56
+ end
57
+
58
+ # IMPLEMENTATION FUNCTIONS
59
+
60
+ def remote_keys(project_key: nil)
61
+ fetch_issue_keys(project_key: project_key)
62
+ end
63
+
64
+ def cached_keys(project_key: nil)
65
+ Data::IssueRepository.keys_in_project(project_key: project_key)
66
+ end
67
+
68
+ def updated_keys(project_key: nil)
69
+ time = latest_sync_time(project_key: project_key)
70
+ fetch_issue_keys(project_key: project_key, updated_since: time)
71
+ end
72
+
73
+ # Fetch from JIRA
74
+
75
+ # Fetch issue keys from JIRA using the specified `JiraCache::Client`
76
+ # instance, for the specified project, with an optional `updated_since`
77
+ # parameter.
78
+ #
79
+ # @param project_key [String]
80
+ # @param updated_since [Time]
81
+ # @return [Array] array of issue keys as strings
82
+ def fetch_issue_keys(project_key: nil, updated_since: nil)
83
+ query_items = []
84
+ query_items << "project = \"#{project_key}\"" unless project_key.nil?
85
+ query_items << "updatedDate > \"#{updated_since.strftime('%Y-%m-%d %H:%M')}\"" unless updated_since.nil?
86
+ query = query_items.join(" AND ")
87
+ client.issue_keys_for_query(query)
88
+ end
89
+
90
+ # @param issue_keys [Array] array of strings representing the JIRA keys
91
+ def fetch_issues(issue_keys, sync_time)
92
+ issue_keys.each do |issue_key|
93
+ sync_issue(issue_key, sync_time: sync_time)
94
+ end
95
+ end
96
+
97
+ def mark_deleted(issue_keys)
98
+ Data::IssueRepository.update_where({ key: issue_keys }, deleted_from_jira_at: Time.now)
99
+ end
100
+
101
+ def latest_sync_time(project_key)
102
+ Data::IssueRepository.latest_sync_time
103
+ end
104
+
105
+ def log(message)
106
+ return if logger.nil?
107
+ logger.info(message)
108
+ end
109
+ end
110
+ end