jira-auto-tool 1.3.7 → 1.3.8
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/.jscpd.json +7 -0
- data/CLAUDE.md +51 -0
- data/Rakefile +6 -1
- data/TODO.,md +3 -0
- data/features/step_definitions/execution_context_steps.rb +0 -11
- data/features/step_definitions/jira_ticket_steps.rb +17 -1
- data/features/support/env.rb +33 -3
- data/lib/jira/auto/tool/sprint_state_controller.rb +16 -3
- data/lib/jira/auto/tool/version.rb +1 -1
- data/lib/jira/auto/tool.rb +8 -12
- data/spec/jira/auto/tool_spec.rb +11 -11
- metadata +5 -37
- data/features/control_http_request_rate_limit.feature +0 -35
- data/lib/jira/auto/tool/rate_limited_jira_client/in_process_based.rb +0 -26
- data/lib/jira/auto/tool/rate_limited_jira_client/redis_based.rb +0 -40
- data/lib/jira/auto/tool/rate_limited_jira_client.rb +0 -50
- data/spec/jira/auto/tool/rate_limited_jira_client/in_process_based_spec.rb +0 -65
- data/spec/jira/auto/tool/rate_limited_jira_client/redis_based_spec.rb +0 -56
- data/spec/jira/auto/tool/rate_limited_jira_client_spec.rb +0 -103
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65ed9f4b2be361932c1b31bb18969304c31057d1db27f29b47a5c41f0f628402
|
|
4
|
+
data.tar.gz: e592e726bdabb7528e23440fefb04bfe9c556f355193bf4c82187571d75a7c07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5aaa9c4165021446076dc4ff58a393eb6ce64c989487c3160bc312e32fb08e1901550689005cb01243d67578a3269254b3ca415514e8784626b04710473f24c2
|
|
7
|
+
data.tar.gz: b8e89f123ab16ab097b9fef0398728b4c4b4395bc4dd8f7d184e0f710cfe0b9e5aafd33f79f9ea3f7b341d07e46e3abf7b40f8481cd5c4ecb1c517904915d4c4
|
data/.jscpd.json
ADDED
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bundle install # install dependencies
|
|
9
|
+
bundle exec rake verify # run all checks (rubocop + spec + cucumber)
|
|
10
|
+
bundle exec rspec # run all unit tests
|
|
11
|
+
bundle exec rspec spec/path/to/file_spec.rb # run a single spec file
|
|
12
|
+
bundle exec cucumber # run all integration tests
|
|
13
|
+
bundle exec cucumber features/foo.feature # run a single feature
|
|
14
|
+
bundle exec guard # continuous testing while developing
|
|
15
|
+
bundle exec rake rubocop # lint (with autocorrect)
|
|
16
|
+
bundle exec rake version:bump[patch] # bump version (major|minor|patch)
|
|
17
|
+
bundle exec rake release # release to rubygems.org
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Cucumber profiles: `default` (pretty output), `guard` (rerun on failure), `rake` (progress, skips `@wip`). Tags `@in-specification` are excluded from all runs; `@wip` only from `rake`.
|
|
21
|
+
|
|
22
|
+
## Architecture
|
|
23
|
+
|
|
24
|
+
`Jira::Auto::Tool` (`lib/jira/auto/tool.rb`) is the central orchestrator. All configuration flows through it: environment variables are read via `EnvironmentBasedValue` and optionally overridden by a YAML config at `~/.config/jira-auto-tool/jira-auto-tool.config.yml`.
|
|
25
|
+
|
|
26
|
+
### Key layers
|
|
27
|
+
|
|
28
|
+
**CLI → Performer → Tool → RequestBuilder → Jira API**
|
|
29
|
+
|
|
30
|
+
- **CLI** (`bin/jira-auto-tool`): OptionParser-based. Options are registered in `*/options.rb` files under each controller/performer namespace.
|
|
31
|
+
- **Controllers** (`BoardController`, `SprintController`, `FieldController`): Orchestrate reads and present tabular output via `terminal-table`.
|
|
32
|
+
- **Performers** (`lib/jira/auto/tool/performer/`): Encapsulate multi-sprint write operations: `SprintRenamer`, `QuarterlySprintRenamer`, `SprintEndDateUpdater`, `SprintTimeInDatesAligner`, `PlanningIncrementSprintCreator`.
|
|
33
|
+
- **RequestBuilder** (`lib/jira/auto/tool/request_builder/`): Abstract base for Jira REST calls. Concrete subclasses implement `http_verb`, `request_path`, `request_payload`, `expected_response`, and message prefix methods.
|
|
34
|
+
- **RateLimitedJiraClient**: Subclasses `JIRA::Client`. Two implementations: `InProcessBased` (default) and `RedisBased`. Selected via the `JAT_RATE_LIMIT_IMPLEMENTATION` env var.
|
|
35
|
+
|
|
36
|
+
### Sprint naming convention
|
|
37
|
+
|
|
38
|
+
Sprints must match `{prefix}_{YY}.{quarter}.{index}` (e.g., `Food_Delivery_25.3.1`). The regex lives in `Sprint::Name::SPRINT_NAME_REGEX`. Sprints that don't match are ignored with a warning.
|
|
39
|
+
|
|
40
|
+
### Board caching
|
|
41
|
+
|
|
42
|
+
`Board::Cache` persists board lists for one day to avoid hammering large Jira instances. Clear it with `Board::Cache.new(tool).clear`.
|
|
43
|
+
|
|
44
|
+
### Environment-based configuration
|
|
45
|
+
|
|
46
|
+
`EnvironmentBasedValue` dynamically generates reader, predicate (`_defined?`), writer, and `_when_defined_else` methods for every env var listed in `Tool::ENVIRONMENT_BASED_VALUE_SYMBOLS`. Config file values take precedence over env vars.
|
|
47
|
+
|
|
48
|
+
### Testing approach
|
|
49
|
+
|
|
50
|
+
- **Unit tests** (`spec/`): RSpec with `verify_partial_doubles`, `expect` syntax only. Coverage enforced at ≥90% overall / ≥80% per file via SimpleCov. Use `jira_resource_double` (defined in `spec_helper.rb`) instead of `double` for JIRA resource objects.
|
|
51
|
+
- **Integration tests** (`features/`): Cucumber + Aruba, connecting to a real Jira sandbox. Each scenario resets state by deleting all sprints and tickets from the board and clearing the board cache in `Before` hooks.
|
data/Rakefile
CHANGED
|
@@ -19,7 +19,12 @@ Cucumber::Rake::Task.new do |t|
|
|
|
19
19
|
t.profile = "rake"
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
desc "Detect copy-paste duplication"
|
|
23
|
+
task :jscpd do
|
|
24
|
+
sh "jscpd lib/ spec/"
|
|
25
|
+
end
|
|
26
|
+
|
|
22
27
|
task default: :verify
|
|
23
28
|
|
|
24
29
|
desc "Run all checks"
|
|
25
|
-
task verify: %i[rubocop spec cucumber]
|
|
30
|
+
task verify: %i[rubocop spec cucumber jscpd]
|
data/TODO.,md
ADDED
|
@@ -13,17 +13,6 @@ Given(/^the following environment variables are set:$/) do |table|
|
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
BUFFER_TIME_IN_SECONDS = 10
|
|
17
|
-
Then(/^successfully running `(.*)` takes between (.*) and (.*) seconds$/) do |command_line, minimal_time, maximal_time|
|
|
18
|
-
start_time = Time.now
|
|
19
|
-
|
|
20
|
-
run_command_and_stop(command_line, fail_on_error: true, timeout: maximal_time.to_i + BUFFER_TIME_IN_SECONDS)
|
|
21
|
-
|
|
22
|
-
end_time = Time.now
|
|
23
|
-
|
|
24
|
-
expect(end_time - start_time).to be_between(minimal_time.to_i, maximal_time.to_i)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
16
|
Given(/^I wait for over a day$/) do
|
|
28
17
|
in_over_a_day = (Time.now + 1.day + 2.minute).to_s
|
|
29
18
|
|
|
@@ -16,10 +16,12 @@ And(/^tickets on the board have a team field named "([^"]*)" with exactly those
|
|
|
16
16
|
expect(@team_field.values.collect(&:value)).to eq(expected_field_values)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
TICKET_INDEX_WAIT_SECONDS = 30
|
|
20
|
+
|
|
19
21
|
Given(/^the following tickets exist:$/) do |ticket_table|
|
|
20
22
|
# Summary | Description | Implementation Team | Expected Start |
|
|
21
23
|
# table is a table.hashes.keys # => [:summary, :team, :expected_start]
|
|
22
|
-
ticket_table.hashes.
|
|
24
|
+
created_keys = ticket_table.hashes.map do |ticket_info|
|
|
23
25
|
log.debug { ticket_info.inspect }
|
|
24
26
|
|
|
25
27
|
jira_ticket = @jira_auto_tool.jira_client.Issue.build
|
|
@@ -35,6 +37,20 @@ Given(/^the following tickets exist:$/) do |ticket_table|
|
|
|
35
37
|
} })
|
|
36
38
|
|
|
37
39
|
log.debug { "created jira ticket: #{jira_ticket.key}" }
|
|
40
|
+
jira_ticket.key
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Jira search index is eventually consistent — poll until all created tickets are searchable
|
|
44
|
+
jql = ENV.fetch("JAT_TICKETS_FOR_TEAM_SPRINT_TICKET_DISPATCHER_JQL",
|
|
45
|
+
"project = #{@jira_auto_tool.board.project_key}")
|
|
46
|
+
deadline = Time.now + TICKET_INDEX_WAIT_SECONDS
|
|
47
|
+
loop do
|
|
48
|
+
found_keys = @jira_auto_tool.jira_client.Issue.jql(jql, fields: ["summary"]).map(&:key)
|
|
49
|
+
break if (created_keys - found_keys).empty?
|
|
50
|
+
break if Time.now >= deadline
|
|
51
|
+
|
|
52
|
+
log.debug { "Waiting for Jira search index: #{(created_keys - found_keys).join(", ")} not yet searchable" }
|
|
53
|
+
sleep 2
|
|
38
54
|
end
|
|
39
55
|
end
|
|
40
56
|
|
data/features/support/env.rb
CHANGED
|
@@ -10,15 +10,45 @@ module JiraSprintToolWorld
|
|
|
10
10
|
|
|
11
11
|
log.debug { "Removing sprints #sprints = #{sprints.size}: #{sprints.map(&:name).join(", ")}" }
|
|
12
12
|
|
|
13
|
-
sprints.each(
|
|
13
|
+
sprints.each { |sprint| delete_sprint(jira_auto_tool, sprint) }
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def remove_existing_board_tickets(jira_auto_tool)
|
|
17
|
-
tickets = jira_auto_tool.jira_client.Issue.jql("project = #{jira_auto_tool.board.project_key}"
|
|
17
|
+
tickets = jira_auto_tool.jira_client.Issue.jql("project = #{jira_auto_tool.board.project_key}",
|
|
18
|
+
fields: ["key"])
|
|
18
19
|
|
|
19
20
|
log.debug { "Removing tickets from board #{jira_auto_tool.board.name}: #tickets = #{tickets.size}" }
|
|
20
21
|
|
|
21
|
-
tickets.each(
|
|
22
|
+
tickets.each { |ticket| delete_ticket(ticket) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def delete_sprint(jira_auto_tool, sprint)
|
|
28
|
+
close_active_sprint(jira_auto_tool, sprint)
|
|
29
|
+
sprint.delete
|
|
30
|
+
rescue JIRA::HTTPError => e
|
|
31
|
+
raise unless e.response.code == "404"
|
|
32
|
+
|
|
33
|
+
log.warn { "Sprint #{sprint.name} not found on delete — already removed or non-deletable state" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete_ticket(ticket)
|
|
37
|
+
ticket.delete
|
|
38
|
+
rescue JIRA::HTTPError => e
|
|
39
|
+
raise unless e.response.code == "404"
|
|
40
|
+
|
|
41
|
+
log.warn { "Ticket #{ticket.key} not found on delete — already removed" }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def close_active_sprint(jira_auto_tool, sprint)
|
|
45
|
+
return unless sprint.state == Jira::Auto::Tool::SprintStateController::SprintState::ACTIVE
|
|
46
|
+
|
|
47
|
+
Jira::Auto::Tool::SprintStateController
|
|
48
|
+
.new(jira_auto_tool.jira_client, sprint)
|
|
49
|
+
.transition_to(Jira::Auto::Tool::SprintStateController::SprintState::CLOSED)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
log.warn { "Failed to close active sprint #{sprint.name} before delete: #{e.message}" }
|
|
22
52
|
end
|
|
23
53
|
end
|
|
24
54
|
|
|
@@ -47,10 +47,23 @@ module Jira
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
MAX_STATE_UPDATE_RETRIES = 3
|
|
51
|
+
STATE_UPDATE_RETRY_DELAY_SECONDS = 2
|
|
52
|
+
|
|
50
53
|
def update_sprint_state(new_state)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
retries = 0
|
|
55
|
+
begin
|
|
56
|
+
RequestBuilder::SprintStateUpdater
|
|
57
|
+
.new(jira_client, sprint: sprint, new_state: new_state)
|
|
58
|
+
.run
|
|
59
|
+
rescue JIRA::HTTPError => e
|
|
60
|
+
raise unless e.response.code == "404" && retries < MAX_STATE_UPDATE_RETRIES
|
|
61
|
+
|
|
62
|
+
retries += 1
|
|
63
|
+
log.warn { "Sprint #{sprint.name} not found on state update (attempt #{retries}), retrying..." }
|
|
64
|
+
sleep STATE_UPDATE_RETRY_DELAY_SECONDS
|
|
65
|
+
retry
|
|
66
|
+
end
|
|
54
67
|
end
|
|
55
68
|
end
|
|
56
69
|
end
|
data/lib/jira/auto/tool.rb
CHANGED
|
@@ -6,12 +6,13 @@ require "active_support/core_ext/numeric/time"
|
|
|
6
6
|
require "active_support/core_ext/date/calculations"
|
|
7
7
|
require "jira-ruby"
|
|
8
8
|
|
|
9
|
+
require "rate_limited_jira"
|
|
10
|
+
|
|
9
11
|
require_relative "tool/config"
|
|
10
12
|
require_relative "tool/board_controller"
|
|
11
13
|
require_relative "tool/environment_loader"
|
|
12
14
|
require_relative "tool/helpers/environment_based_value"
|
|
13
15
|
require_relative "tool/project"
|
|
14
|
-
require_relative "tool/rate_limited_jira_client"
|
|
15
16
|
require_relative "tool/request_builder"
|
|
16
17
|
require_relative "tool/setup_logging"
|
|
17
18
|
require_relative "tool/sprint_controller"
|
|
@@ -83,17 +84,12 @@ module Jira
|
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
def jira_client
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
).to_i,
|
|
93
|
-
rate_limit_per_interval:
|
|
94
|
-
jat_rate_limit_per_interval_when_defined_else(
|
|
95
|
-
RateLimitedJiraClient::RedisBased::NO_RATE_LIMIT_PER_INTERVAL
|
|
96
|
-
).to_i)
|
|
87
|
+
RateLimitedJira::Client.build(
|
|
88
|
+
jira_client_options,
|
|
89
|
+
rate_interval_in_seconds: jat_rate_interval_in_seconds_when_defined_else(0).to_i,
|
|
90
|
+
rate_limit_per_interval: jat_rate_limit_per_interval_when_defined_else(0).to_i,
|
|
91
|
+
implementation: jat_rate_limit_implementation_when_defined_else("")
|
|
92
|
+
)
|
|
97
93
|
end
|
|
98
94
|
|
|
99
95
|
def jira_client_options
|
data/spec/jira/auto/tool_spec.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Jira
|
|
|
18
18
|
describe "#board_controller" do
|
|
19
19
|
before do
|
|
20
20
|
allow(tool)
|
|
21
|
-
.to receive_messages(jira_client: instance_double(
|
|
21
|
+
.to receive_messages(jira_client: instance_double(RateLimitedJira::Client))
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
it { expect(tool.board_controller).to be_a(BoardController) }
|
|
@@ -122,7 +122,7 @@ module Jira
|
|
|
122
122
|
|
|
123
123
|
describe "#project" do
|
|
124
124
|
let(:jira_client) do
|
|
125
|
-
instance_double(
|
|
125
|
+
instance_double(RateLimitedJira::Client, Project: project_query)
|
|
126
126
|
end
|
|
127
127
|
let(:jira_project) { instance_double(JIRA::Resource::Project) }
|
|
128
128
|
let(:jira_project_key) { "JIRA_PROJECT_KEY" }
|
|
@@ -275,7 +275,7 @@ module Jira
|
|
|
275
275
|
}
|
|
276
276
|
end
|
|
277
277
|
|
|
278
|
-
let(:expected_jira_client) { instance_double(
|
|
278
|
+
let(:expected_jira_client) { instance_double(RateLimitedJira::Client) }
|
|
279
279
|
|
|
280
280
|
before do
|
|
281
281
|
allow(tool)
|
|
@@ -284,16 +284,16 @@ module Jira
|
|
|
284
284
|
jira_context_path_when_defined_else: "/context_path_value",
|
|
285
285
|
jira_http_debug?: false,
|
|
286
286
|
jat_rate_limit_per_interval_when_defined_else: "10",
|
|
287
|
-
jat_rate_interval_in_seconds_when_defined_else: "60"
|
|
287
|
+
jat_rate_interval_in_seconds_when_defined_else: "60",
|
|
288
|
+
jat_rate_limit_implementation_when_defined_else: "")
|
|
288
289
|
end
|
|
289
290
|
|
|
290
291
|
it "has a jira client" do
|
|
291
|
-
allow(
|
|
292
|
-
.to receive(:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
.and_return(expected_jira_client)
|
|
292
|
+
allow(RateLimitedJira::Client)
|
|
293
|
+
.to receive(:build).with(client_options, rate_limit_per_interval: 10,
|
|
294
|
+
rate_interval_in_seconds: 60,
|
|
295
|
+
implementation: "")
|
|
296
|
+
.and_return(expected_jira_client)
|
|
297
297
|
|
|
298
298
|
expect(tool.jira_client).to equal(expected_jira_client)
|
|
299
299
|
end
|
|
@@ -425,7 +425,7 @@ module Jira
|
|
|
425
425
|
|
|
426
426
|
describe "#tickets" do
|
|
427
427
|
let(:query) { jira_resource_double("query") }
|
|
428
|
-
let(:jira_client) { instance_double(
|
|
428
|
+
let(:jira_client) { instance_double(RateLimitedJira::Client, Issue: query) }
|
|
429
429
|
|
|
430
430
|
before do
|
|
431
431
|
allow(tool).to receive_messages(jira_client: jira_client)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jira-auto-tool
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.3.
|
|
4
|
+
version: 1.3.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Christophe Broult
|
|
@@ -108,7 +108,7 @@ dependencies:
|
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
109
|
version: '0'
|
|
110
110
|
- !ruby/object:Gem::Dependency
|
|
111
|
-
name:
|
|
111
|
+
name: rate-limited-jira-ruby
|
|
112
112
|
requirement: !ruby/object:Gem::Requirement
|
|
113
113
|
requirements:
|
|
114
114
|
- - ">="
|
|
@@ -149,20 +149,6 @@ dependencies:
|
|
|
149
149
|
- - ">="
|
|
150
150
|
- !ruby/object:Gem::Version
|
|
151
151
|
version: '0'
|
|
152
|
-
- !ruby/object:Gem::Dependency
|
|
153
|
-
name: redis
|
|
154
|
-
requirement: !ruby/object:Gem::Requirement
|
|
155
|
-
requirements:
|
|
156
|
-
- - ">="
|
|
157
|
-
- !ruby/object:Gem::Version
|
|
158
|
-
version: '0'
|
|
159
|
-
type: :runtime
|
|
160
|
-
prerelease: false
|
|
161
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
162
|
-
requirements:
|
|
163
|
-
- - ">="
|
|
164
|
-
- !ruby/object:Gem::Version
|
|
165
|
-
version: '0'
|
|
166
152
|
- !ruby/object:Gem::Dependency
|
|
167
153
|
name: reline
|
|
168
154
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -177,20 +163,6 @@ dependencies:
|
|
|
177
163
|
- - ">="
|
|
178
164
|
- !ruby/object:Gem::Version
|
|
179
165
|
version: '0'
|
|
180
|
-
- !ruby/object:Gem::Dependency
|
|
181
|
-
name: ruby-limiter
|
|
182
|
-
requirement: !ruby/object:Gem::Requirement
|
|
183
|
-
requirements:
|
|
184
|
-
- - ">="
|
|
185
|
-
- !ruby/object:Gem::Version
|
|
186
|
-
version: '0'
|
|
187
|
-
type: :runtime
|
|
188
|
-
prerelease: false
|
|
189
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
190
|
-
requirements:
|
|
191
|
-
- - ">="
|
|
192
|
-
- !ruby/object:Gem::Version
|
|
193
|
-
version: '0'
|
|
194
166
|
- !ruby/object:Gem::Dependency
|
|
195
167
|
name: syslog
|
|
196
168
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -226,15 +198,18 @@ executables:
|
|
|
226
198
|
extensions: []
|
|
227
199
|
extra_rdoc_files: []
|
|
228
200
|
files:
|
|
201
|
+
- ".jscpd.json"
|
|
229
202
|
- ".rspec"
|
|
230
203
|
- ".rubocop.yml"
|
|
231
204
|
- ".ruby-version"
|
|
232
205
|
- CHANGELOG.md
|
|
206
|
+
- CLAUDE.md
|
|
233
207
|
- CODE_OF_CONDUCT.md
|
|
234
208
|
- Guardfile
|
|
235
209
|
- LICENSE.txt
|
|
236
210
|
- README.md
|
|
237
211
|
- Rakefile
|
|
212
|
+
- TODO.,md
|
|
238
213
|
- bin/jira-auto-tool
|
|
239
214
|
- bin/jira-auto-tool.bat
|
|
240
215
|
- config/examples/jira-auto-tool.env.yaml.erb
|
|
@@ -247,7 +222,6 @@ files:
|
|
|
247
222
|
- features/assign_tickets_to_team_sprints.feature
|
|
248
223
|
- features/cache_boards.feature
|
|
249
224
|
- features/configure_environment.feature
|
|
250
|
-
- features/control_http_request_rate_limit.feature
|
|
251
225
|
- features/create_sprints_using_existing_ones_as_reference.feature
|
|
252
226
|
- features/list_boards.feature
|
|
253
227
|
- features/list_project_fields.feature
|
|
@@ -302,9 +276,6 @@ files:
|
|
|
302
276
|
- lib/jira/auto/tool/project.rb
|
|
303
277
|
- lib/jira/auto/tool/project/options.rb
|
|
304
278
|
- lib/jira/auto/tool/project/ticket_fields.rb
|
|
305
|
-
- lib/jira/auto/tool/rate_limited_jira_client.rb
|
|
306
|
-
- lib/jira/auto/tool/rate_limited_jira_client/in_process_based.rb
|
|
307
|
-
- lib/jira/auto/tool/rate_limited_jira_client/redis_based.rb
|
|
308
279
|
- lib/jira/auto/tool/request_builder.rb
|
|
309
280
|
- lib/jira/auto/tool/request_builder/field_context_fetcher.rb
|
|
310
281
|
- lib/jira/auto/tool/request_builder/field_option_fetcher.rb
|
|
@@ -355,9 +326,6 @@ files:
|
|
|
355
326
|
- spec/jira/auto/tool/performer/sprint_time_in_dates_aligner_spec.rb
|
|
356
327
|
- spec/jira/auto/tool/project/ticket_fields_spec.rb
|
|
357
328
|
- spec/jira/auto/tool/project_spec.rb
|
|
358
|
-
- spec/jira/auto/tool/rate_limited_jira_client/in_process_based_spec.rb
|
|
359
|
-
- spec/jira/auto/tool/rate_limited_jira_client/redis_based_spec.rb
|
|
360
|
-
- spec/jira/auto/tool/rate_limited_jira_client_spec.rb
|
|
361
329
|
- spec/jira/auto/tool/request_builder/field_context_fetcher_spec.rb
|
|
362
330
|
- spec/jira/auto/tool/request_builder/field_option_fetcher_spec.rb
|
|
363
331
|
- spec/jira/auto/tool/request_builder/get_spec.rb
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
Feature: Control the HTTP request rate limit
|
|
2
|
-
In order to work against a JIRA instance imposing API rate limits
|
|
3
|
-
As a user
|
|
4
|
-
I need the ability to control the HTTP request limit
|
|
5
|
-
|
|
6
|
-
Scenario Outline: Limiting the request rate
|
|
7
|
-
Given a Jira Scrum board
|
|
8
|
-
And the board only has the following sprints:
|
|
9
|
-
| name | length | start_date | state |
|
|
10
|
-
| ART-16_CRM_24.4.1 | 2-week | 2024-12-01 11:00:00 UTC | closed |
|
|
11
|
-
| ART-16_E2E-Test_24.4.2 | 4-day | 2024-12-05 11:00:00 UTC | future |
|
|
12
|
-
| ART-32_Platform_24.4.7 | 3-week | 2024-10-07 11:00:00 UTC | future |
|
|
13
|
-
And the following environment variables are set:
|
|
14
|
-
| name | value |
|
|
15
|
-
| JAT_RATE_INTERVAL_IN_SECONDS | <rate_interval_in_seconds> |
|
|
16
|
-
| JAT_RATE_LIMIT_IMPLEMENTATION | <jat_rate_limit_implementation> |
|
|
17
|
-
| JAT_RATE_LIMIT_PER_INTERVAL | <rate_limit_per_interval> |
|
|
18
|
-
Then successfully running `jira-auto-tool --board-list --sprint-prefix` takes between <minimal_time> and <maximal_time> seconds
|
|
19
|
-
|
|
20
|
-
Examples:
|
|
21
|
-
| jat_rate_limit_implementation | rate_limit_per_interval | rate_interval_in_seconds | minimal_time | maximal_time |
|
|
22
|
-
| | 0 | 0 | 0 | 5 |
|
|
23
|
-
| in_process | 1 | 1 | 1 | 20 |
|
|
24
|
-
| redis | 1 | 2 | 1 | 20 |
|
|
25
|
-
| redis | 1 | 10 | 18 | 120 |
|
|
26
|
-
|
|
27
|
-
Scenario: Unexpected rate limiting implementation generates an error
|
|
28
|
-
Given the following environment variables are set:
|
|
29
|
-
| name | value |
|
|
30
|
-
| JAT_RATE_LIMIT_IMPLEMENTATION | UNKNOWN IMPLEMENTATION |
|
|
31
|
-
When I run `jira-auto-tool --board-list`
|
|
32
|
-
Then it should fail with:
|
|
33
|
-
"""
|
|
34
|
-
RuntimeError: "UNKNOWN IMPLEMENTATION": unexpected rate limiting implementation specified!
|
|
35
|
-
"""
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ruby-limiter"
|
|
4
|
-
|
|
5
|
-
require_relative "../rate_limited_jira_client"
|
|
6
|
-
|
|
7
|
-
module Jira
|
|
8
|
-
module Auto
|
|
9
|
-
class Tool
|
|
10
|
-
class RateLimitedJiraClient
|
|
11
|
-
class InProcessBased < RateLimitedJiraClient
|
|
12
|
-
def rate_limit(&block)
|
|
13
|
-
rate_queue.shift
|
|
14
|
-
|
|
15
|
-
block.call
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def rate_queue
|
|
19
|
-
@rate_queue ||=
|
|
20
|
-
Limiter::RateQueue.new(rate_limit_per_interval, interval: rate_interval_in_seconds)
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../rate_limited_jira_client"
|
|
4
|
-
require "ratelimit"
|
|
5
|
-
require "redis"
|
|
6
|
-
|
|
7
|
-
require "jira/auto/tool"
|
|
8
|
-
|
|
9
|
-
module Jira
|
|
10
|
-
module Auto
|
|
11
|
-
class Tool
|
|
12
|
-
class RateLimitedJiraClient
|
|
13
|
-
class RedisBased < RateLimitedJiraClient
|
|
14
|
-
def rate_limit(&block)
|
|
15
|
-
rate_limiter.exec_within_threshold(rate_limiter_key, interval: rate_interval_in_seconds,
|
|
16
|
-
threshold: rate_limit_per_interval) do
|
|
17
|
-
response = block.call
|
|
18
|
-
|
|
19
|
-
rate_limiter.add(rate_limiter_key)
|
|
20
|
-
|
|
21
|
-
response
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def rate_limiter_key
|
|
26
|
-
"jira_auto_tool_api_requests"
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def rate_limiter
|
|
30
|
-
self.class.rate_limiter(rate_limiter_key, rate_interval_in_seconds)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def self.rate_limiter(rate_limiter_key, rate_interval)
|
|
34
|
-
@rate_limiter ||= Ratelimit.new(rate_limiter_key, bucket_interval: rate_interval)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Jira
|
|
4
|
-
module Auto
|
|
5
|
-
class Tool
|
|
6
|
-
class RateLimitedJiraClient < JIRA::Client
|
|
7
|
-
require_relative "rate_limited_jira_client/in_process_based"
|
|
8
|
-
require_relative "rate_limited_jira_client/redis_based"
|
|
9
|
-
|
|
10
|
-
def self.implementation_class_for(tool)
|
|
11
|
-
requested_implementation = tool.jat_rate_limit_implementation_when_defined_else nil
|
|
12
|
-
|
|
13
|
-
case requested_implementation
|
|
14
|
-
when "in_process", "", nil
|
|
15
|
-
InProcessBased
|
|
16
|
-
when "redis"
|
|
17
|
-
RedisBased
|
|
18
|
-
else
|
|
19
|
-
raise %(#{requested_implementation.inspect}: unexpected rate limiting implementation specified!")
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
NO_RATE_LIMIT_PER_INTERVAL = 0
|
|
24
|
-
NO_RATE_INTERVAL_IN_SECONDS = 0
|
|
25
|
-
|
|
26
|
-
attr_reader :rate_interval_in_seconds, :rate_limit_per_interval
|
|
27
|
-
|
|
28
|
-
def initialize(options, rate_interval_in_seconds: 1, rate_limit_per_interval: 1)
|
|
29
|
-
super(options)
|
|
30
|
-
@rate_interval_in_seconds = rate_interval_in_seconds
|
|
31
|
-
@rate_limit_per_interval = rate_limit_per_interval
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
alias original_request request
|
|
35
|
-
|
|
36
|
-
def request(*)
|
|
37
|
-
if rate_limit_per_interval == NO_RATE_LIMIT_PER_INTERVAL
|
|
38
|
-
original_request(*)
|
|
39
|
-
else
|
|
40
|
-
rate_limit { original_request(*) }
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def rate_limit(&)
|
|
45
|
-
raise "rate_limit must be implemented by a subclass"
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "jira/auto/tool/rate_limited_jira_client/in_process_based"
|
|
4
|
-
|
|
5
|
-
module Jira
|
|
6
|
-
module Auto
|
|
7
|
-
class Tool
|
|
8
|
-
class RateLimitedJiraClient
|
|
9
|
-
class InProcessBased
|
|
10
|
-
RSpec.describe InProcessBased do
|
|
11
|
-
def build_client
|
|
12
|
-
described_class.new({}, rate_interval_in_seconds:, rate_limit_per_interval:)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
let(:client) { build_client }
|
|
16
|
-
|
|
17
|
-
let(:rate_interval_in_seconds) { 2 }
|
|
18
|
-
let(:rate_limit_per_interval) { 1 }
|
|
19
|
-
|
|
20
|
-
describe "#rate_limit" do
|
|
21
|
-
let(:rate_queue) { instance_double(Limiter::RateQueue) }
|
|
22
|
-
|
|
23
|
-
it "properly initializes the rate queue" do
|
|
24
|
-
allow(Limiter::RateQueue)
|
|
25
|
-
.to receive(:new).with(rate_limit_per_interval, interval: rate_interval_in_seconds)
|
|
26
|
-
.and_return(rate_queue)
|
|
27
|
-
|
|
28
|
-
allow(rate_queue).to receive(:shift)
|
|
29
|
-
|
|
30
|
-
expect(client.rate_limit { :do_nothing }).to eq(:do_nothing)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
context "when rate limiting multiple requests" do
|
|
34
|
-
let(:rate_limit_4_calls_to_original_request_code) do
|
|
35
|
-
4.times { client.rate_limit { client.original_request(:get, "/path/to/resource") } }
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
before do
|
|
39
|
-
allow(client).to receive(:original_request).with(:get, "/path/to/resource")
|
|
40
|
-
allow(client).to receive_messages(rate_queue: rate_queue)
|
|
41
|
-
allow(rate_queue).to receive(:shift)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
it "shifts the queue and performs the request call" do
|
|
45
|
-
rate_limit_4_calls_to_original_request_code
|
|
46
|
-
|
|
47
|
-
expect(rate_queue).to have_received(:shift).exactly(4).times
|
|
48
|
-
expect(client).to have_received(:original_request).exactly(4).times
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
describe "#rate_queue" do
|
|
53
|
-
let(:another_client) { build_client }
|
|
54
|
-
|
|
55
|
-
it "creating a second client will return another queue" do
|
|
56
|
-
expect(another_client.rate_queue).not_to equal(client.rate_queue)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "jira/auto/tool/rate_limited_jira_client/redis_based"
|
|
4
|
-
|
|
5
|
-
module Jira
|
|
6
|
-
module Auto
|
|
7
|
-
class Tool
|
|
8
|
-
class RateLimitedJiraClient
|
|
9
|
-
class RedisBased
|
|
10
|
-
RSpec.describe RedisBased do
|
|
11
|
-
describe "#rate_limit" do
|
|
12
|
-
let(:client) { described_class.new({}, rate_interval_in_seconds:, rate_limit_per_interval:) }
|
|
13
|
-
|
|
14
|
-
let(:rate_interval_in_seconds) { 2 }
|
|
15
|
-
let(:rate_limit_per_interval) { 1 }
|
|
16
|
-
let(:rate_limiter) { instance_double(Ratelimit) }
|
|
17
|
-
|
|
18
|
-
let(:rate_limit_4_calls_to_original_request_code) do
|
|
19
|
-
4.times { client.rate_limit { client.original_request(:get, "/path/to/resource") } }
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
before do
|
|
23
|
-
allow(described_class).to receive_messages(rate_limiter: rate_limiter)
|
|
24
|
-
allow(client).to receive(:original_request)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
it "uses :exec_within_threshold to control rate limiting" do
|
|
28
|
-
allow(rate_limiter).to receive(:exec_within_threshold)
|
|
29
|
-
|
|
30
|
-
rate_limit_4_calls_to_original_request_code
|
|
31
|
-
|
|
32
|
-
expect(rate_limiter)
|
|
33
|
-
.to have_received(:exec_within_threshold)
|
|
34
|
-
.with("jira_auto_tool_api_requests", { interval: rate_interval_in_seconds,
|
|
35
|
-
threshold: rate_limit_per_interval })
|
|
36
|
-
.exactly(4).times
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
it "adds keeps track of the rate limiter key calls" do
|
|
40
|
-
allow(rate_limiter).to receive(:exec_within_threshold).and_yield
|
|
41
|
-
allow(rate_limiter).to receive(:add)
|
|
42
|
-
|
|
43
|
-
rate_limit_4_calls_to_original_request_code
|
|
44
|
-
|
|
45
|
-
expect(rate_limiter)
|
|
46
|
-
.to have_received(:add)
|
|
47
|
-
.with("jira_auto_tool_api_requests")
|
|
48
|
-
.exactly(4).times
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rspec"
|
|
4
|
-
|
|
5
|
-
module Jira
|
|
6
|
-
module Auto
|
|
7
|
-
class Tool
|
|
8
|
-
RSpec.describe RateLimitedJiraClient do
|
|
9
|
-
describe ".implementation_class_for" do
|
|
10
|
-
let(:result) { described_class.implementation_class_for(tool) }
|
|
11
|
-
let(:tool) { instance_double(Tool) }
|
|
12
|
-
|
|
13
|
-
context "when the rate limiting implementation is unspecified" do
|
|
14
|
-
before do
|
|
15
|
-
allow(tool).to receive_messages(jat_rate_limit_implementation_when_defined_else: nil)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
it { expect(result).to eq(RateLimitedJiraClient::InProcessBased) }
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
context "when using in process based rate limiting" do
|
|
22
|
-
before do
|
|
23
|
-
allow(tool).to receive_messages(jat_rate_limit_implementation_when_defined_else: "in_process")
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
it { expect(result).to eq(RateLimitedJiraClient::InProcessBased) }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
context "when using in Redis based rate limiting" do
|
|
30
|
-
before do
|
|
31
|
-
allow(tool).to receive_messages(jat_rate_limit_implementation_when_defined_else: "redis")
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
it { expect(result).to eq(RateLimitedJiraClient::RedisBased) }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
context "when the request implementation is unexpected" do
|
|
38
|
-
before do
|
|
39
|
-
allow(tool)
|
|
40
|
-
.to receive_messages(jat_rate_limit_implementation_when_defined_else: "unexpected_implementation")
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
it do
|
|
44
|
-
expect { result }
|
|
45
|
-
.to raise_error(RuntimeError,
|
|
46
|
-
%("unexpected_implementation": unexpected rate limiting implementation specified!"))
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
RSpec.shared_examples "a rate limited client" do
|
|
52
|
-
before do
|
|
53
|
-
allow(client).to receive_messages(original_request: :response)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
it "returns the response" do
|
|
57
|
-
expect(client.request(:get, "/path/to/resource")).to eq(:response)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
it "calls the original request method" do
|
|
61
|
-
client.request(:get, "/path/to/resource")
|
|
62
|
-
|
|
63
|
-
expect(client).to have_received(:original_request).with(:get, "/path/to/resource")
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
describe "#request" do
|
|
68
|
-
let(:client) { described_class.new({}, rate_interval_in_seconds:, rate_limit_per_interval:) }
|
|
69
|
-
|
|
70
|
-
context "when the rate limiter is not needed" do
|
|
71
|
-
let(:rate_interval_in_seconds) { 0 }
|
|
72
|
-
let(:rate_limit_per_interval) { 0 }
|
|
73
|
-
|
|
74
|
-
it_behaves_like "a rate limited client"
|
|
75
|
-
|
|
76
|
-
it "does not use the rate limiter" do
|
|
77
|
-
allow(client).to receive(:original_request).with(:get, "/path/to/resource")
|
|
78
|
-
expect(client).not_to receive(:rate_limit)
|
|
79
|
-
|
|
80
|
-
client.request(:get, "/path/to/resource")
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
context "when the rate limiter is needed" do
|
|
85
|
-
let(:rate_interval_in_seconds) { 2 }
|
|
86
|
-
let(:rate_limit_per_interval) { 1 }
|
|
87
|
-
|
|
88
|
-
it_behaves_like "a rate limited client" do
|
|
89
|
-
before { allow(client).to receive(:rate_limit).and_yield }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
it "uses the rate limiter" do
|
|
93
|
-
allow(client).to receive(:original_request).with(:get, "/path/to/resource")
|
|
94
|
-
expect(client).to receive(:rate_limit).and_yield
|
|
95
|
-
|
|
96
|
-
client.request(:get, "/path/to/resource")
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|