jira_cache 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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