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,3 @@
1
+ module JiraCache
2
+ VERSION = File.read(File.expand_path('../../../VERSION', __FILE__)).strip
3
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ require "sinatra/base"
3
+ require "json"
4
+ require "jira_cache/sync"
5
+
6
+ module JiraCache
7
+
8
+ # A Sinatra::Base application to process JIRA webhooks.
9
+ #
10
+ # Defines 2 routes:
11
+ # - GET /: provides a basic JSON status,
12
+ # - POST /: which processes a webhook.
13
+ class WebhookApp < Sinatra::Base
14
+
15
+ # GET /
16
+ # Returns JSON with the app name and a status
17
+ get "/" do
18
+ default_response
19
+ end
20
+
21
+ # POST /
22
+ # Endpoint for JIRA webhook
23
+ post "/" do
24
+ client = self.class.client
25
+ request.body.rewind # in case it was already read
26
+ data = JSON.parse(request.body.read)
27
+ issue_key = data["issue"]["key"]
28
+
29
+ case (webhook_event = data["webhookEvent"])
30
+ when "jira:issue_created", "jira:issue_updated", "jira:worklog_updated"
31
+ JiraCache::Sync.new(client).sync_issue(issue_key)
32
+ when "jira:issue_deleted"
33
+ JiraCache::Sync.new.mark_deleted([issue_key])
34
+ else
35
+ raise "Unknown webhook event \"#{webhook_event}\""
36
+ end
37
+
38
+ default_response
39
+ end
40
+
41
+ # Returns the client (`JiraCache::Client`) defined on
42
+ # the class (see `JiraCache.webhook_app(...)`).
43
+ def client
44
+ self.class.client
45
+ end
46
+
47
+ def default_response
48
+ {
49
+ app: "jira_cache/webhook_app",
50
+ status: "ok",
51
+ client: client.info
52
+ }.to_json
53
+ end
54
+ end
55
+ end
@@ -0,0 +1 @@
1
+ {"expand":"schema,names","startAt":0,"maxResults":5,"total":11,"issues":[{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"001","self":"https://example.com/rest/api/2/issue/001","key":"MR-001"},{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"002","self":"https://example.com/rest/api/2/issue/002","key":"MR-002"},{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"003","self":"https://example.com/rest/api/2/issue/003","key":"MR-003"},{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"004","self":"https://example.com/rest/api/2/issue/004","key":"MR-004"},{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"005","self":"https://example.com/rest/api/2/issue/005","key":"MR-005"}]}
@@ -0,0 +1 @@
1
+ {"expand":"schema,names","startAt":10,"maxResults":5,"total":11,"issues":[{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"011","self":"https://example.com/rest/api/2/issue/011","key":"MR-011"}]}
@@ -0,0 +1 @@
1
+ {"expand":"schema,names","startAt":5,"maxResults":5,"total":11,"issues":[{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"006","self":"https://example.com/rest/api/2/issue/006","key":"MR-006"},{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"007","self":"https://example.com/rest/api/2/issue/007","key":"MR-007"},{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"008","self":"https://example.com/rest/api/2/issue/008","key":"MR-008"},{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"009","self":"https://example.com/rest/api/2/issue/009","key":"MR-009"},{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"010","self":"https://example.com/rest/api/2/issue/010","key":"MR-010"}]}
@@ -0,0 +1 @@
1
+ {"expand":"schema,names","startAt":0,"maxResults":5,"total":2,"issues":[{"expand":"operations,versionedrepresentations,editmeta,changelog,transitions,renderedfields","id":"001","self":"https://example.com/rest/api/2/issue/001","key":"MR-001"},{"expand":"operations,versionedRepresentations,editmeta,changelog,transitions,renderedFields","id":"002","self":"https://example.com/rest/api/2/issue/002","key":"MR-002"}]}
@@ -0,0 +1 @@
1
+ {"fields":{"worklog":{"total":"3","maxResults":"2","worklogs":["fakeWorklog1","fakeWorklog2"]}}}
@@ -0,0 +1 @@
1
+ {"errorMessages":["Issue Does Not Exist"],"errors":{}}
@@ -0,0 +1 @@
1
+ {"fields":{"worklog":{"total":"1","maxResults":"2"}}}
@@ -0,0 +1 @@
1
+ {"total":"3","maxResults":"3","worklogs":["fakeWorklog1","fakeWorklog2","fakeWorklog3"]}
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ # Load dependencies
3
+ require "rubygems"
4
+ require "bundler/setup"
5
+ require "rspec"
6
+ require "webmock/rspec"
7
+ require "rack/test"
8
+ require "pry"
9
+
10
+ require "simplecov"
11
+ SimpleCov.start do
12
+ add_filter do |src|
13
+ # Ignoring files from the spec directory
14
+ src.filename =~ %r{/spec/}
15
+ end
16
+ end
17
+
18
+ # ENV["APP_ENV"] replaces "RACK_ENV" since we're not in
19
+ # a Rack context.
20
+ ENV["APP_ENV"] = "test"
21
+ require File.expand_path("../../config/boot", __FILE__)
22
+
23
+ Dir[File.expand_path("../support/**/*.rb", __FILE__)].each { |f| require(f) }
24
+
25
+ # Database setup, teardown and cleanup during tests
26
+ require "sequel/extensions/migration"
27
+ require "jira_cache/data"
28
+ require "jira_cache/data/issue_repository"
29
+ client = JiraCache::Data::DB
30
+
31
+ MIGRATIONS_DIR = File.expand_path("../../config/db_migrations", __FILE__)
32
+ RSpec.configure do |config|
33
+
34
+ config.before(:all) do
35
+ Sequel::Migrator.apply(client, MIGRATIONS_DIR)
36
+ end
37
+
38
+ config.after(:each) do
39
+ JiraCache::Data::IssueRepository.delete_where("TRUE")
40
+ end
41
+
42
+ config.after(:all) do
43
+ Sequel::Migrator.apply(client, MIGRATIONS_DIR, 0)
44
+ end
45
+ end
46
+
47
+ require "jira_cache"
@@ -0,0 +1,16 @@
1
+ # Saves and loads response to be used as fixtures for HTTP request
2
+ # responses.
3
+ class ResponseFixture
4
+
5
+ def self.create(identifier, payload)
6
+ File.write(file(identifier), payload.to_json)
7
+ end
8
+
9
+ def self.get(identifier)
10
+ File.read(file(identifier))
11
+ end
12
+
13
+ def self.file(identifier)
14
+ File.expand_path("../../fixtures/responses/#{identifier}.json", __FILE__)
15
+ end
16
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+ require "jira_cache/client"
4
+ require "support/response_fixture"
5
+ require "webmock/rspec"
6
+
7
+ describe JiraCache::Client do
8
+ let(:domain) { "example.com" }
9
+ let(:username) { "username" }
10
+ let(:password) { "password" }
11
+ let(:notifier) { nil }
12
+
13
+ let(:client) do
14
+ described_class.new(
15
+ domain: domain,
16
+ username: username,
17
+ password: password,
18
+ notifier: notifier
19
+ )
20
+ end
21
+
22
+ describe "#issue_data(id_or_key)" do
23
+ let(:response) { ResponseFixture.get("get_issue_#{issue_key}") }
24
+ let(:headers) do
25
+ {
26
+ "Content-Type" => "application/json",
27
+ "Authorization" => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
28
+ }
29
+ end
30
+ let(:url) { "https://#{domain}/rest/api/2/issue/#{issue_key}" }
31
+ let(:url_query) { "?expand=renderedFields,changelog" }
32
+
33
+ before do
34
+ stub_request(:get, "#{url}#{url_query}")
35
+ .with(headers: headers)
36
+ .to_return(status: 200, body: response, headers: headers)
37
+ end
38
+
39
+ context "issue not found" do
40
+ let(:issue_key) { "not_found" }
41
+
42
+ it "returns nil" do
43
+ result = client.issue_data(issue_key)
44
+ expect(result).to be_nil
45
+ end
46
+ end
47
+
48
+ context 'simple issue' do
49
+ let(:issue_key) { 'simple' }
50
+ let(:notifier) { double('Notifier', publish: nil) }
51
+ let(:issue_data) { JSON.parse(response) }
52
+
53
+ it 'fetches the issue data' do
54
+ result = client.issue_data(issue_key)
55
+ expect(result.keys).to include('fields')
56
+ end
57
+
58
+ it 'publish an event through the notifier' do
59
+ expect(notifier).to receive(:publish).with('fetched_issue', key: issue_key, data: issue_data)
60
+ client.issue_data(issue_key)
61
+ end
62
+ end
63
+
64
+ context 'issue with lots of worklog' do
65
+ let(:issue_key) { 'many_worklogs' }
66
+
67
+ it 'fetches all worklogs' do
68
+ worklog_response = ResponseFixture.get("get_issue_worklog_#{issue_key}")
69
+ stub_request(:get, "#{url}/worklog")
70
+ .with(headers: headers)
71
+ .to_return(status: 200, body: worklog_response, headers: headers)
72
+
73
+ result = client.issue_data(issue_key)
74
+ expect(result['fields']['worklog']['worklogs'].count).to eq(3)
75
+ end
76
+ end
77
+ end
78
+
79
+ describe "#issue_keys_for_query(jql_query)" do
80
+
81
+ context "single request query" do
82
+ let(:jql_query) { 'project="single_request"' }
83
+ # let(:jql_query) { 'project="JT"' }
84
+
85
+ it "returns ids from the query results" do
86
+ url = "https://#{domain}/rest/api/2/search"
87
+ url_query = "?fields=id&jql=#{jql_query}&maxResults=1000&startAt=0"
88
+ headers = { "Content-Type" => "application/json" }
89
+ response = ResponseFixture.get('get_issue_keys_jql_query_project="single_request"_start_at_0')
90
+
91
+ stub_request(:get, "#{url}#{url_query}")
92
+ .with(headers: headers)
93
+ .to_return(status: 200, body: response, headers: headers)
94
+
95
+ result = client.issue_keys_for_query(jql_query)
96
+ expect(result.count).to eq(2)
97
+ end
98
+ end
99
+
100
+ context "query spanning over multiple requests" do
101
+ let(:jql_query) { 'project="multiple_requests"' }
102
+
103
+ it "returns ids from the multiple requests" do
104
+ url = "https://#{domain}/rest/api/2/search"
105
+ url_query_1 = "?fields=id&jql=#{jql_query}&maxResults=1000&startAt=0"
106
+ url_query_2 = "?fields=id&jql=#{jql_query}&maxResults=1000&startAt=5"
107
+ url_query_3 = "?fields=id&jql=#{jql_query}&maxResults=1000&startAt=10"
108
+ headers = { "Content-Type" => "application/json" }
109
+
110
+ response_fixture_prefix = 'get_issue_keys_jql_query_project="multiple_requests"_start_at_'
111
+ response_1 = ResponseFixture.get("#{response_fixture_prefix}0")
112
+ response_2 = ResponseFixture.get("#{response_fixture_prefix}5")
113
+ response_3 = ResponseFixture.get("#{response_fixture_prefix}10")
114
+
115
+ stub_request(:get, "#{url}#{url_query_1}")
116
+ .with(headers: headers)
117
+ .to_return(status: 200, body: response_1, headers: headers)
118
+ stub_request(:get, "#{url}#{url_query_2}")
119
+ .with(headers: headers)
120
+ .to_return(status: 200, body: response_2, headers: headers)
121
+ stub_request(:get, "#{url}#{url_query_3}")
122
+ .with(headers: headers)
123
+ .to_return(status: 200, body: response_3, headers: headers)
124
+
125
+ result = client.issue_keys_for_query(jql_query)
126
+ expect(result.count).to eq(11)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+ require "timecop"
4
+ require "jira_cache/data/issue_repository"
5
+
6
+ describe JiraCache::Data::IssueRepository do
7
+ let(:time) { Time.now }
8
+ let(:issue1_data) { { "key" => "key1", "fields" => { "project" => { "key" => "PJ1" } }, "value" => "value1" } }
9
+ let(:issue2_data) { { "key" => "key2", "fields" => { "project" => { "key" => "PJ2" } }, "value" => "value2" } }
10
+ let(:issue3_data) { { "key" => "key3", "fields" => { "project" => { "key" => "PJ1" } }, "value" => "value3" } }
11
+
12
+ before do
13
+ Timecop.freeze(time)
14
+ described_class.insert(key: "key1", data: issue1_data, synced_at: time)
15
+ described_class.insert(key: "key2", data: issue2_data, synced_at: time)
16
+ described_class.insert(key: "key3", data: issue3_data, synced_at: time)
17
+ end
18
+ after { Timecop.return }
19
+
20
+ describe "::find_by_key(issue_key)" do
21
+
22
+ context "matching issue exists" do
23
+ it "returns the issue's attributes" do
24
+ result = described_class.find_by_key("key1")
25
+ expect(result[:data]["value"]).to eq("value1")
26
+ end
27
+ end
28
+
29
+ context "no matching issue" do
30
+ it "returns nil" do
31
+ expect(described_class.find_by_key("unknown")).to eq(nil)
32
+ end
33
+ end
34
+ end
35
+
36
+ describe "::insert(key:, data:, synced_at:, deleted_from_jira_at: nil)" do
37
+ context "successful" do
38
+ let(:key) { SecureRandom.uuid }
39
+ subject do
40
+ described_class.insert(key: key, data: issue1_data, synced_at: time)
41
+ end
42
+
43
+ it "returns nil" do
44
+ expect(subject).to eq(nil)
45
+ end
46
+
47
+ it "created the row" do
48
+ subject
49
+ expect(JiraCache::Data::IssueRepository.find_by_key(key)).not_to be_nil
50
+ end
51
+ end
52
+ end
53
+
54
+ describe "::keys_in_project(project_key)" do
55
+ subject { described_class.keys_in_project("PJ1") }
56
+ it { should eq(%w(key1 key3)) }
57
+ end
58
+ end
@@ -0,0 +1,18 @@
1
+ require "spec_helper"
2
+ require "jira_cache/notifier"
3
+
4
+ describe JiraCache::Notifier do
5
+
6
+ describe "::published(name, data = nil)" do
7
+ let(:event_name) { "test" }
8
+ let(:issue_data) { { "value" => "issue_value", "key" => "issue_data_key" } }
9
+ let(:event_data) { { key: "issue_key", data: issue_data } }
10
+
11
+ it "logs the event name and event\"s data key" do
12
+ logger = ::Logger.new("/dev/null")
13
+ notifier = described_class.new(logger)
14
+ expect(logger).to receive(:info).with("[test] #{event_data[:key]}")
15
+ notifier.publish event_name, event_data
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+ require "timecop"
4
+ require "jira_cache/sync"
5
+
6
+ describe JiraCache::Sync do
7
+
8
+ subject { described_class.new(client) }
9
+ let(:client) { double("JiraCache::Client", issue_data: {}, logger: nil) }
10
+ let(:now) { Time.now }
11
+
12
+ before do
13
+ Timecop.freeze(now)
14
+ end
15
+
16
+ after { Timecop.return }
17
+
18
+ it "has a version number" do
19
+ expect(JiraCache::VERSION).not_to be nil
20
+ end
21
+
22
+ let(:project_key) { "project_key" }
23
+ let(:remote_keys) { %w(a b c d) }
24
+ let(:cached_keys) { %w(c d e f) }
25
+ let(:updated_keys) { %w(c) }
26
+ let(:latest_sync_time) { now }
27
+
28
+ describe "::sync_issues(project_key: nil)" do
29
+
30
+ before do
31
+ expect(subject)
32
+ .to receive(:remote_keys)
33
+ .with(project_key: project_key)
34
+ .and_return(remote_keys)
35
+ expect(subject)
36
+ .to receive(:cached_keys)
37
+ .with(project_key: project_key)
38
+ .and_return(cached_keys)
39
+ expect(subject)
40
+ .to receive(:updated_keys)
41
+ .with(project_key: project_key)
42
+ .and_return(updated_keys)
43
+ end
44
+
45
+ it "fetches new and updated issues" do
46
+ expect(subject).to receive(:fetch_issues).with(%w(a b c), now)
47
+ subject.sync_issues(project_key: project_key)
48
+ end
49
+
50
+ it "marks deleted issues" do
51
+ expect(subject).to receive(:mark_deleted).with(%w(e f))
52
+ subject.sync_issues(project_key: project_key)
53
+ end
54
+
55
+ it "stores issues with the sync time" do
56
+ subject.sync_issues(project_key: project_key)
57
+ expect(JiraCache::Data::IssueRepository.latest_sync_time).to be_within(1).of latest_sync_time
58
+ end
59
+ end
60
+
61
+ describe "::remote_keys(project_key: nil)" do
62
+ it "fetches the issue keys for the project" do
63
+ expect(subject).to receive(:fetch_issue_keys).with(project_key: project_key)
64
+ subject.remote_keys(project_key: project_key)
65
+ end
66
+ end
67
+
68
+ describe "::cached_keys(project_key: nil)" do
69
+ it "fetches keys from cached issues" do
70
+ expect(JiraCache::Data::IssueRepository)
71
+ .to receive(:keys_in_project)
72
+ .with(project_key: project_key)
73
+ subject.cached_keys(project_key: project_key)
74
+ end
75
+ end
76
+
77
+ describe "::updated_keys(project_key: nil)" do
78
+ it "fetch issue keys for the project updated from the last sync date" do
79
+ expect(subject).to receive(:latest_sync_time).and_return(latest_sync_time)
80
+ expect(subject).to receive(:fetch_issue_keys).with(project_key: project_key, updated_since: latest_sync_time)
81
+ subject.updated_keys(project_key: project_key)
82
+ end
83
+ end
84
+
85
+ describe "::fetch_issue_keys(project_key: nil, updated_since: nil)" do
86
+
87
+ context "with no parameter" do
88
+ it "fetches issue keys with an empty JQL query" do
89
+ expect(client)
90
+ .to receive(:issue_keys_for_query)
91
+ .with("")
92
+ subject.fetch_issue_keys()
93
+ end
94
+ end
95
+
96
+ context "with only the `project_key` parameter" do
97
+ it "fetches issue keys with the project JQL query" do
98
+ expect(client)
99
+ .to receive(:issue_keys_for_query)
100
+ .with("project = \"#{project_key}\"")
101
+ subject.fetch_issue_keys(project_key: project_key)
102
+ end
103
+ end
104
+
105
+ context "with both parameters" do
106
+ it "fetches issue keys with the project and updated_since JQL query" do
107
+ expected_jql = "project = \"#{project_key}\""
108
+ expected_jql += " AND updatedDate > \"#{latest_sync_time.strftime('%Y-%m-%d %H:%M')}\""
109
+ expect(client)
110
+ .to receive(:issue_keys_for_query)
111
+ .with(expected_jql)
112
+ subject.fetch_issue_keys(project_key: project_key, updated_since: latest_sync_time)
113
+ end
114
+ end
115
+ end
116
+ end