shiftcare-toolkit 0.1.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 +7 -0
- data/CHANGELOG.md +8 -0
- data/README.md +246 -0
- data/exe/shiftcare-toolkit +6 -0
- data/lib/shiftcare_toolkit/cli.rb +577 -0
- data/lib/shiftcare_toolkit/client.rb +163 -0
- data/lib/shiftcare_toolkit/config.rb +96 -0
- data/lib/shiftcare_toolkit/version.rb +5 -0
- data/lib/shiftcare_toolkit.rb +6 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8197a28b179e8ad2102311ada3a7b15cfe01f95a0a81ff97937e3d9d682fef09
|
|
4
|
+
data.tar.gz: f490ba1cb22ae90e4b2dda411477193c835260b26c6ebb347e3a3e6c3ce2f06f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5307120569d6c5d3fc493a13a71384e25f77533156b2658ef6695326fcf732ed88259f3f31f011e74146600d89654b35f21e85358c71b032bbbbf5b7c0be518b
|
|
7
|
+
data.tar.gz: 05baed828c4b22ebdf1941ca0e4d26ee31a8382b62bf1da38b7f6ae9b3bb7ee5ee9479c9cac916617221ef840c2265da0d9b74362f1d7ca2faf0a82f6ff780a3
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-10-02
|
|
4
|
+
|
|
5
|
+
- First release: `setup`, `regions` (`status`), `list`, `show`, `create`, `update` and `copy`
|
|
6
|
+
against `/internal/product_releases` in au, us, uk, ca and custom regions.
|
|
7
|
+
- Custom `User-Agent`, 1 request/second per region, bounded retry on 429.
|
|
8
|
+
- Config in `~/.config/shiftcare-toolkit/config.yml` (0600), `SHIFTCARE_TOOLKIT_TOKEN_<REGION>` overrides.
|
data/README.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# shiftcare-toolkit
|
|
2
|
+
|
|
3
|
+
Internal ShiftCare command-line tools, one namespace per tool. Region tokens are set up
|
|
4
|
+
once and shared by every tool.
|
|
5
|
+
|
|
6
|
+
| Command | What it does |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `shiftcare-toolkit setup` / `regions` | Save and check a token per region (au, us, uk, ca or custom) |
|
|
9
|
+
| `shiftcare-toolkit releases ...` | Product Releases API (`/internal/product_releases`): list, show, create, update, copy between regions |
|
|
10
|
+
|
|
11
|
+
Runtime is the Ruby standard library only. Ruby 3.1 or later.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
gem install shiftcare-toolkit
|
|
17
|
+
shiftcare-toolkit setup
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
From the git repo:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
git clone https://github.com/shiftcare/shiftcare-toolkit.git
|
|
24
|
+
cd shiftcare-toolkit
|
|
25
|
+
gem build shiftcare-toolkit.gemspec
|
|
26
|
+
gem install ./shiftcare-toolkit-*.gem --local
|
|
27
|
+
shiftcare-toolkit --help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If someone hands you a built `.gem`, `gem install ./shiftcare-toolkit-0.1.0.gem --local` is enough.
|
|
31
|
+
|
|
32
|
+
To run from a checkout without installing: `bundle install && bundle exec exe/shiftcare-toolkit --help`.
|
|
33
|
+
|
|
34
|
+
## Regions
|
|
35
|
+
|
|
36
|
+
| Region | Base URL |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `au` (default) | https://app.shiftcare.com |
|
|
39
|
+
| `us` | https://us.shiftcare.com |
|
|
40
|
+
| `uk` | https://uk.shiftcare.com |
|
|
41
|
+
| `ca` | https://ca.shiftcare.com |
|
|
42
|
+
|
|
43
|
+
Add a custom region, such as staging or local, with `setup --region NAME --url URL`.
|
|
44
|
+
Every command takes `--region R`. Without it the CLI uses `default_region` from the
|
|
45
|
+
config file (`au` until you change it).
|
|
46
|
+
|
|
47
|
+
## Tokens
|
|
48
|
+
|
|
49
|
+
Each region issues its own tokens, and **a token works only in the region that issued it**.
|
|
50
|
+
You need one token per region you want to use.
|
|
51
|
+
|
|
52
|
+
To create one, sign in to that region's super admin console and open
|
|
53
|
+
**Admin -> Admin API tokens** (the key icon in the header) -> **Create token**:
|
|
54
|
+
|
|
55
|
+
- Name: say what and where, for example `Release bot AU (yourname)`.
|
|
56
|
+
- Scope: `product_releases:write`.
|
|
57
|
+
- Expiry: 30 or 90 days.
|
|
58
|
+
|
|
59
|
+
The token (`scadm_...`) is shown once. Copy it straight into `setup`.
|
|
60
|
+
When it expires or is revoked, the CLI reports `401`; create a new one and run `setup` again.
|
|
61
|
+
|
|
62
|
+
## Setup
|
|
63
|
+
|
|
64
|
+
Interactive, walks au, us, uk and ca. Input is hidden; press Enter to skip a region.
|
|
65
|
+
Each token is checked against the API before it is saved.
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
$ shiftcare-toolkit setup
|
|
69
|
+
Paste a token for each region (input is hidden), or press Enter to skip.
|
|
70
|
+
au (https://app.shiftcare.com) token:
|
|
71
|
+
au: ok, saved scadm_7Hk2pQ…
|
|
72
|
+
us (https://us.shiftcare.com) token:
|
|
73
|
+
us: skipped
|
|
74
|
+
...
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Non-interactive, one region at a time. The token comes from stdin, never from an argument,
|
|
78
|
+
so it stays out of your shell history:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
pbpaste | shiftcare-toolkit setup --region us --token-stdin
|
|
82
|
+
shiftcare-toolkit setup --region uk --token-stdin < uk-token.txt
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Custom region:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
shiftcare-toolkit setup --region staging --url https://staging.example.com
|
|
89
|
+
pbpaste | shiftcare-toolkit setup --region staging --token-stdin
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The config lives in `~/.config/shiftcare-toolkit/config.yml` (or
|
|
93
|
+
`$XDG_CONFIG_HOME/shiftcare-toolkit/config.yml`), mode 0600 in a 0700 directory:
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
default_region: au # edit to change the default
|
|
97
|
+
regions:
|
|
98
|
+
au:
|
|
99
|
+
token: scadm_...
|
|
100
|
+
staging:
|
|
101
|
+
url: https://staging.example.com
|
|
102
|
+
token: scadm_...
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`SHIFTCARE_TOOLKIT_TOKEN_<REGION>` overrides the stored token, for example
|
|
106
|
+
`SHIFTCARE_TOOLKIT_TOKEN_AU` or `SHIFTCARE_TOOLKIT_TOKEN_STAGING`. Use it in CI.
|
|
107
|
+
|
|
108
|
+
The CLI never prints a full token: output, errors and `--verbose` logs show the prefix only.
|
|
109
|
+
|
|
110
|
+
## Commands
|
|
111
|
+
|
|
112
|
+
Run `shiftcare-toolkit help COMMAND` or `shiftcare-toolkit COMMAND --help` for details.
|
|
113
|
+
|
|
114
|
+
Global options: `--region R`, `--verbose` (logs each request to stderr), `--version`.
|
|
115
|
+
|
|
116
|
+
### regions (alias: status)
|
|
117
|
+
|
|
118
|
+
Every region with its URL, masked token, where the token came from, and a live check
|
|
119
|
+
(`ok`, `401`, `403`, `unreachable`, `no token`).
|
|
120
|
+
|
|
121
|
+
```console
|
|
122
|
+
$ shiftcare-toolkit regions
|
|
123
|
+
REGION URL TOKEN CHECK
|
|
124
|
+
au https://app.shiftcare.com scadm_7Hk2pQ… (config) ok (42 releases)
|
|
125
|
+
us https://us.shiftcare.com scadm_Lm9xQa… (env) 401
|
|
126
|
+
uk https://uk.shiftcare.com - no token
|
|
127
|
+
ca https://ca.shiftcare.com - no token
|
|
128
|
+
default region: au
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### releases list
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
shiftcare-toolkit releases list # first page, table
|
|
135
|
+
shiftcare-toolkit releases list --region us --status dev --visibility hidden
|
|
136
|
+
shiftcare-toolkit releases list --page 2
|
|
137
|
+
shiftcare-toolkit releases list --all --json > au-releases.json # every page
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The table shows key, status, visibility, updated_at and title. 50 releases per page.
|
|
141
|
+
|
|
142
|
+
### releases show
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
shiftcare-toolkit releases show shift_swaps
|
|
146
|
+
shiftcare-toolkit releases show shift_swaps --region uk --json
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### releases create
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
shiftcare-toolkit releases create --region au --file release.json
|
|
153
|
+
shiftcare-toolkit releases show shift_swaps --json | shiftcare-toolkit releases create --region us --file -
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`release.json` holds `key` plus any writable fields. The file can be bare fields,
|
|
157
|
+
`{"product_release": {...}}`, or `show --json` output; other fields are dropped.
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"key": "shift_swaps",
|
|
162
|
+
"title": "Shift swaps",
|
|
163
|
+
"summary": "Workers can swap shifts without calling the office.",
|
|
164
|
+
"categories": ["scheduling"],
|
|
165
|
+
"change_items": ["Request a swap from the app", "Approve swaps in one click"],
|
|
166
|
+
"links": [{ "label": "Help article", "url": "https://help.shiftcare.com/..." }]
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
New releases are always `status: dev`, `visibility: hidden`. `409` if the key exists
|
|
171
|
+
(including soft-deleted releases), `422` if the release is invalid.
|
|
172
|
+
|
|
173
|
+
### releases update
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
shiftcare-toolkit releases update shift_swaps --set title="Shift swaps v2"
|
|
177
|
+
shiftcare-toolkit releases update shift_swaps --set change_items="Swap a shift" --set change_items="Approve a swap"
|
|
178
|
+
shiftcare-toolkit releases update shift_swaps --set 'links=[{"label":"Help","url":"https://help.shiftcare.com"}]'
|
|
179
|
+
shiftcare-toolkit releases update shift_swaps --region us --file patch.json
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Array fields (`categories`, `change_items`, `flag_keys`) take one item per `--set`, or a JSON
|
|
183
|
+
array. `links` takes a JSON array. `video_duration_seconds` takes an integer. `--set` overrides
|
|
184
|
+
fields from `--file`. Only hidden dev drafts can be updated; anything else is a `409`.
|
|
185
|
+
|
|
186
|
+
### releases copy
|
|
187
|
+
|
|
188
|
+
Copies releases (key + writable fields) from one region to others.
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
shiftcare-toolkit releases copy --from au --to us,uk,ca --dry-run # see what would change
|
|
192
|
+
shiftcare-toolkit releases copy --from au --to us,uk,ca # create what is missing
|
|
193
|
+
shiftcare-toolkit releases copy --from au --to us,uk,ca --key shift_swaps --key rosters_v2
|
|
194
|
+
shiftcare-toolkit releases copy --from au --to us --update-existing # also update existing drafts
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- Keys missing in the target are created as hidden dev drafts.
|
|
198
|
+
- Keys that exist are skipped, unless `--update-existing`: then they are updated if their
|
|
199
|
+
fields differ. A target that is no longer a hidden dev draft answers `409`; it is reported and
|
|
200
|
+
skipped, so published releases are never overwritten.
|
|
201
|
+
- Empty (null) source fields are not sent, so copy never clears a field on the target.
|
|
202
|
+
- It ends with a summary per region and exits 1 if anything failed:
|
|
203
|
+
|
|
204
|
+
```text
|
|
205
|
+
us: created 3, updated 0, skipped 1, failed 0
|
|
206
|
+
uk: created 4, updated 0, skipped 0, failed 0
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Writable fields
|
|
210
|
+
|
|
211
|
+
`title summary rich_body owner icon tone help_url video_url video_title
|
|
212
|
+
video_duration_seconds categories[] change_items[] flag_keys[] links[{label,url}]`
|
|
213
|
+
|
|
214
|
+
## Limits
|
|
215
|
+
|
|
216
|
+
- No delete, no publish, no status or visibility changes, no screenshots or announcements
|
|
217
|
+
through the API. Do those in the super admin console.
|
|
218
|
+
- 60 requests per minute per token. The CLI sends at most one request per second per region
|
|
219
|
+
and retries `429` responses with backoff (up to 5 times).
|
|
220
|
+
- Every write is recorded against the admin who created the token.
|
|
221
|
+
|
|
222
|
+
## Errors
|
|
223
|
+
|
|
224
|
+
| Code | Meaning |
|
|
225
|
+
|---|---|
|
|
226
|
+
| 401 | Token bad, expired or revoked, or used in the wrong region |
|
|
227
|
+
| 403 | Token lacks the `product_releases:write` scope, or Cloudflare blocked the request (error 1010) |
|
|
228
|
+
| 404 | No release with that key (or it was deleted) |
|
|
229
|
+
| 409 | Key already taken (create), or the release is not a hidden dev draft (update) |
|
|
230
|
+
| 422 | Invalid release or filter; the message lists the errors |
|
|
231
|
+
|
|
232
|
+
## Development
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
bundle install
|
|
236
|
+
bundle exec rake # runs the tests; none touch the network
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The HTTP layer is injected (`Client.new(http: ->(uri, request) { ... })`), and
|
|
240
|
+
`test/test_helper.rb` has a small in-memory fake of the API.
|
|
241
|
+
|
|
242
|
+
## Adding a tool
|
|
243
|
+
|
|
244
|
+
Add a top-level entry to `COMMANDS` in `lib/shiftcare_toolkit/cli.rb` and dispatch its
|
|
245
|
+
subcommands the way `releases` does (`RELEASE_COMMANDS`). Reuse `Config` for region URLs
|
|
246
|
+
and tokens and `Client` for HTTP (user agent, throttle, 429 retry).
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require_relative "client"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "version"
|
|
9
|
+
|
|
10
|
+
module ShiftcareToolkit
|
|
11
|
+
class CLI
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
COMMANDS = {
|
|
15
|
+
"setup" => "Save a token per region, verified against the API first",
|
|
16
|
+
"regions" => "List regions, their URLs, masked tokens and a live check (alias: status)",
|
|
17
|
+
"releases" => "Product releases: list, show, create, update, copy",
|
|
18
|
+
}.freeze
|
|
19
|
+
RELEASE_COMMANDS = {
|
|
20
|
+
"list" => "List releases in a region",
|
|
21
|
+
"show" => "Show one release",
|
|
22
|
+
"create" => "Create a hidden dev release from a JSON file",
|
|
23
|
+
"update" => "Update a hidden dev release",
|
|
24
|
+
"copy" => "Copy releases from one region to others",
|
|
25
|
+
}.freeze
|
|
26
|
+
ARRAY_FIELDS = %w[categories change_items flag_keys links].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr, config: nil, http: nil, min_interval: 1.0,
|
|
29
|
+
sleeper: ->(s) { sleep(s) })
|
|
30
|
+
@stdin = stdin
|
|
31
|
+
@stdout = stdout
|
|
32
|
+
@stderr = stderr
|
|
33
|
+
@config = config
|
|
34
|
+
@http = http
|
|
35
|
+
@min_interval = min_interval
|
|
36
|
+
@sleeper = sleeper
|
|
37
|
+
@clients = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run(argv)
|
|
41
|
+
argv = argv.dup
|
|
42
|
+
@opts = { region: nil, verbose: false }
|
|
43
|
+
while argv.first&.start_with?("-")
|
|
44
|
+
case argv.shift
|
|
45
|
+
when "-v", "--version" then return out(VERSION)
|
|
46
|
+
when "-h", "--help" then return out(usage)
|
|
47
|
+
when "--verbose" then @opts[:verbose] = true
|
|
48
|
+
when "--region" then @opts[:region] = argv.shift
|
|
49
|
+
else return fail_with("unknown option. Run `shiftcare-toolkit help`.", 2)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
command = argv.shift
|
|
54
|
+
command = "regions" if command == "status"
|
|
55
|
+
return help(*argv.first(2)) if command.nil? || command == "help"
|
|
56
|
+
return out(VERSION) if command == "version"
|
|
57
|
+
return fail_with("unknown command #{command}. Run `shiftcare-toolkit help`.", 2) unless COMMANDS.key?(command)
|
|
58
|
+
|
|
59
|
+
if command == "releases"
|
|
60
|
+
command = argv.shift
|
|
61
|
+
return out(releases_usage) if command.nil? || %w[help -h --help].include?(command)
|
|
62
|
+
unless RELEASE_COMMANDS.key?(command)
|
|
63
|
+
return fail_with("unknown command releases #{command}. Run `shiftcare-toolkit releases help`.", 2)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
parser = parser_for(command)
|
|
68
|
+
args = parser.parse(argv)
|
|
69
|
+
return out(parser.help) if @opts[:help]
|
|
70
|
+
|
|
71
|
+
send("cmd_#{command}", args)
|
|
72
|
+
rescue OptionParser::ParseError => e
|
|
73
|
+
fail_with("#{e.message}. Run `shiftcare-toolkit #{RELEASE_COMMANDS.key?(command) ? "releases " : ""}#{command} --help`.", 2)
|
|
74
|
+
rescue Error, Client::Error, Config::Error, JSON::ParserError, SystemCallError => e
|
|
75
|
+
fail_with(e.message, 1)
|
|
76
|
+
rescue Interrupt
|
|
77
|
+
fail_with("interrupted", 130)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# ---- commands ----
|
|
83
|
+
|
|
84
|
+
def cmd_setup(args)
|
|
85
|
+
raise Error, "setup takes no arguments; pipe the token in with --token-stdin" if args.any?
|
|
86
|
+
|
|
87
|
+
region = @opts[:region]
|
|
88
|
+
unless region
|
|
89
|
+
@stderr.puts "Paste a token for each region (input is hidden), or press Enter to skip."
|
|
90
|
+
@stderr.puts "Create tokens in each region's admin console: Admin -> Admin API tokens."
|
|
91
|
+
config.regions.each { |r| setup_interactive(r) }
|
|
92
|
+
return out("Saved to #{config.path}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
config.set(region, url: @opts[:url]) if @opts[:url]
|
|
96
|
+
config.url(region) # fails early for a custom region without a URL
|
|
97
|
+
if @opts[:token_stdin]
|
|
98
|
+
token = @stdin.read.to_s.strip
|
|
99
|
+
raise Error, "no token on stdin" if token.empty?
|
|
100
|
+
|
|
101
|
+
verify(region, token)
|
|
102
|
+
config.set(region, token: token)
|
|
103
|
+
config.save
|
|
104
|
+
out("#{region}: token #{Config.mask(token)} verified and saved to #{config.path}")
|
|
105
|
+
elsif @opts[:url]
|
|
106
|
+
config.save
|
|
107
|
+
out("#{region}: URL #{config.url(region)} saved to #{config.path}")
|
|
108
|
+
else
|
|
109
|
+
setup_interactive(region)
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cmd_regions(_args)
|
|
115
|
+
rows = config.regions.map do |region|
|
|
116
|
+
url = begin
|
|
117
|
+
config.url(region)
|
|
118
|
+
rescue Config::Error
|
|
119
|
+
"-"
|
|
120
|
+
end
|
|
121
|
+
token = config.token(region)
|
|
122
|
+
[region, url, token ? "#{Config.mask(token)} (#{config.token_source(region)})" : "-", check(region, token)]
|
|
123
|
+
end
|
|
124
|
+
table(%w[REGION URL TOKEN CHECK], rows)
|
|
125
|
+
@stdout.puts "default region: #{config.default_region}"
|
|
126
|
+
0
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def cmd_list(_args)
|
|
130
|
+
filters = { status: @opts[:status], visibility: @opts[:visibility] }
|
|
131
|
+
if @opts[:all]
|
|
132
|
+
releases = client(region).list_all(**filters)
|
|
133
|
+
total = releases.size
|
|
134
|
+
else
|
|
135
|
+
body = client(region).list(page: @opts[:page] || 1, **filters)
|
|
136
|
+
releases = body["data"]
|
|
137
|
+
meta = body["meta"]
|
|
138
|
+
total = meta["total"]
|
|
139
|
+
end
|
|
140
|
+
return out(JSON.pretty_generate(releases)) if @opts[:json]
|
|
141
|
+
|
|
142
|
+
table(%w[KEY STATUS VISIBILITY UPDATED_AT TITLE],
|
|
143
|
+
releases.map { |r| [r["key"], r["status"], r["visibility"], r["updated_at"], truncate(r["title"], 60)] })
|
|
144
|
+
if meta && meta["page"] * meta["per_page"] < total
|
|
145
|
+
@stdout.puts "page #{meta["page"]}, #{releases.size} of #{total}. Use --page N or --all for more."
|
|
146
|
+
end
|
|
147
|
+
0
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def cmd_show(args)
|
|
151
|
+
release = client(region).get(one_key(args))
|
|
152
|
+
return out(JSON.pretty_generate(release)) if @opts[:json]
|
|
153
|
+
|
|
154
|
+
release.each do |field, value|
|
|
155
|
+
@stdout.puts "#{field}: #{value.is_a?(Array) || value.is_a?(Hash) ? JSON.generate(value) : value}"
|
|
156
|
+
end
|
|
157
|
+
0
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def cmd_create(args)
|
|
161
|
+
raise Error, "create takes no arguments; put the key in the file" if args.any?
|
|
162
|
+
raise Error, "--file is required" unless @opts[:file]
|
|
163
|
+
|
|
164
|
+
fields = read_release_file(@opts[:file])
|
|
165
|
+
raise Error, "the file needs a \"key\"" if fields["key"].to_s.empty?
|
|
166
|
+
|
|
167
|
+
release = client(region).create(release_fields(fields).merge("key" => fields["key"]))
|
|
168
|
+
out("#{region}: created #{release["key"]} (#{release["status"]}, #{release["visibility"]})")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def cmd_update(args)
|
|
172
|
+
key = one_key(args)
|
|
173
|
+
fields = @opts[:file] ? release_fields(read_release_file(@opts[:file])) : {}
|
|
174
|
+
items = Hash.new { |h, k| h[k] = [] }
|
|
175
|
+
(@opts[:set] || []).each do |field, value|
|
|
176
|
+
fields[field] = ARRAY_FIELDS.include?(field) && !value.is_a?(Array) ? (items[field] << value) : value
|
|
177
|
+
end
|
|
178
|
+
raise Error, "nothing to update: pass --file or --set" if fields.empty?
|
|
179
|
+
|
|
180
|
+
release = client(region).update(key, fields)
|
|
181
|
+
out("#{region}: updated #{release["key"]}")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def cmd_copy(_args)
|
|
185
|
+
from = @opts[:from] || raise(Error, "--from is required")
|
|
186
|
+
targets = @opts[:to] || raise(Error, "--to is required")
|
|
187
|
+
raise Error, "--to must not include the source region #{from}" if targets.include?(from)
|
|
188
|
+
|
|
189
|
+
source = client(from)
|
|
190
|
+
releases = if @opts[:keys]
|
|
191
|
+
@opts[:keys].map do |key|
|
|
192
|
+
source.get(key)
|
|
193
|
+
rescue Client::Error => e
|
|
194
|
+
raise Error, "#{key}: #{e.message}"
|
|
195
|
+
end
|
|
196
|
+
else
|
|
197
|
+
source.list_all
|
|
198
|
+
end
|
|
199
|
+
@stdout.puts "#{@opts[:dry_run] ? "[dry run] " : ""}copying #{releases.size} release(s) from #{from} to #{targets.join(", ")}"
|
|
200
|
+
|
|
201
|
+
summaries = targets.map { |target| [target, copy_to(target, releases)] }
|
|
202
|
+
@stdout.puts
|
|
203
|
+
summaries.each do |target, c|
|
|
204
|
+
@stdout.puts "#{target}: created #{c[:created]}, updated #{c[:updated]}, skipped #{c[:skipped]}, failed #{c[:failed]}"
|
|
205
|
+
end
|
|
206
|
+
summaries.sum { |_, c| c[:failed] }.zero? ? 0 : 1
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ---- copy ----
|
|
210
|
+
|
|
211
|
+
def copy_to(target, releases)
|
|
212
|
+
counts = { created: 0, updated: 0, skipped: 0, failed: 0 }
|
|
213
|
+
existing = begin
|
|
214
|
+
client(target).list_all.to_h { |r| [r["key"], r] }
|
|
215
|
+
rescue Client::Error, Config::Error => e
|
|
216
|
+
@stderr.puts "#{target}: #{e.message}"
|
|
217
|
+
counts[:failed] = releases.size
|
|
218
|
+
return counts
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
releases.each do |release|
|
|
222
|
+
key = release["key"]
|
|
223
|
+
fields = release_fields(release)
|
|
224
|
+
current = existing[key]
|
|
225
|
+
if current.nil?
|
|
226
|
+
write(target, key, counts, :created, "create") { client(target).create(fields.merge("key" => key)) }
|
|
227
|
+
elsif !@opts[:update_existing]
|
|
228
|
+
skip(target, key, counts, "exists (use --update-existing to overwrite)")
|
|
229
|
+
elsif release_fields(current) == fields
|
|
230
|
+
skip(target, key, counts, "unchanged")
|
|
231
|
+
else
|
|
232
|
+
write(target, key, counts, :updated, "update") { client(target).update(key, fields) }
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
counts
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def write(target, key, counts, result, verb)
|
|
239
|
+
if @opts[:dry_run]
|
|
240
|
+
@stdout.puts " #{target} #{key}: would #{verb}"
|
|
241
|
+
else
|
|
242
|
+
yield
|
|
243
|
+
@stdout.puts " #{target} #{key}: #{result}"
|
|
244
|
+
end
|
|
245
|
+
counts[result] += 1
|
|
246
|
+
rescue Client::Error => e
|
|
247
|
+
if e.status == 409
|
|
248
|
+
skip(target, key, counts, "not a hidden dev draft, edit it in the super admin console")
|
|
249
|
+
else
|
|
250
|
+
@stdout.puts " #{target} #{key}: FAILED #{e.message}"
|
|
251
|
+
counts[:failed] += 1
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def skip(target, key, counts, reason)
|
|
256
|
+
@stdout.puts " #{target} #{key}: skipped, #{reason}"
|
|
257
|
+
counts[:skipped] += 1
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# ---- helpers ----
|
|
261
|
+
|
|
262
|
+
def setup_interactive(region)
|
|
263
|
+
url = config.url(region)
|
|
264
|
+
loop do
|
|
265
|
+
current = config.token(region)
|
|
266
|
+
@stderr.print "#{region} (#{url}) token#{current ? " [current #{Config.mask(current)}]" : ""}: "
|
|
267
|
+
token = read_secret
|
|
268
|
+
if token.empty?
|
|
269
|
+
@stderr.puts " #{region}: skipped"
|
|
270
|
+
return
|
|
271
|
+
end
|
|
272
|
+
begin
|
|
273
|
+
verify(region, token)
|
|
274
|
+
config.set(region, token: token)
|
|
275
|
+
config.save
|
|
276
|
+
@stderr.puts " #{region}: ok, saved #{Config.mask(token)}"
|
|
277
|
+
return
|
|
278
|
+
rescue Client::Error => e
|
|
279
|
+
@stderr.puts " #{e.message}. Not saved; try again or press Enter to skip."
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def read_secret
|
|
285
|
+
line = @stdin.tty? && @stdin.respond_to?(:getpass) ? @stdin.getpass("") : @stdin.gets
|
|
286
|
+
line.to_s.strip
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def verify(region, token)
|
|
290
|
+
@clients.delete(region)
|
|
291
|
+
build_client(region, token).list(page: 1)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def check(region, token)
|
|
295
|
+
return "no token" unless token
|
|
296
|
+
|
|
297
|
+
total = client(region).list(page: 1).dig("meta", "total")
|
|
298
|
+
"ok (#{total} releases)"
|
|
299
|
+
rescue Client::Error => e
|
|
300
|
+
e.status ? e.status.to_s : "unreachable"
|
|
301
|
+
rescue Config::Error
|
|
302
|
+
"no url"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def region
|
|
306
|
+
@opts[:region] || config.default_region
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def client(region)
|
|
310
|
+
@clients[region] ||= build_client(region, config.token(region))
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def build_client(region, token)
|
|
314
|
+
log = @opts[:verbose] ? ->(message) { @stderr.puts(message) } : nil
|
|
315
|
+
Client.new(base_url: config.url(region), token: token, region: region, http: @http,
|
|
316
|
+
min_interval: @min_interval, sleeper: @sleeper, log: log)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def config
|
|
320
|
+
@config ||= Config.new
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def one_key(args)
|
|
324
|
+
raise Error, "expected exactly one KEY" unless args.size == 1
|
|
325
|
+
|
|
326
|
+
args.first
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Accepts bare fields, {"product_release": {...}} or `show --json` output.
|
|
330
|
+
def read_release_file(path)
|
|
331
|
+
json = JSON.parse(path == "-" ? @stdin.read : File.read(path))
|
|
332
|
+
json = json["product_release"] || json["data"] || json if json.is_a?(Hash)
|
|
333
|
+
raise Error, "#{path} must hold a JSON object" unless json.is_a?(Hash)
|
|
334
|
+
|
|
335
|
+
json
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# ponytail: nil fields are dropped, so copy never clears a field on the target.
|
|
339
|
+
def release_fields(release)
|
|
340
|
+
fields = release.slice(*Client::WRITABLE).compact
|
|
341
|
+
fields["links"] = fields["links"].map { |l| l.slice("label", "url") } if fields["links"].is_a?(Array)
|
|
342
|
+
fields
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def parse_set(pair)
|
|
346
|
+
field, value = pair.split("=", 2)
|
|
347
|
+
raise OptionParser::InvalidArgument, "#{pair} (expected field=value)" if value.nil?
|
|
348
|
+
unless Client::WRITABLE.include?(field)
|
|
349
|
+
raise OptionParser::InvalidArgument, "#{field} (writable: #{Client::WRITABLE.join(", ")})"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
[field, coerce(field, value)]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def coerce(field, value)
|
|
356
|
+
return value.empty? ? nil : Integer(value) if field == "video_duration_seconds"
|
|
357
|
+
return JSON.parse(value) if field == "links" || (ARRAY_FIELDS.include?(field) && value.start_with?("["))
|
|
358
|
+
|
|
359
|
+
value
|
|
360
|
+
rescue ArgumentError, JSON::ParserError
|
|
361
|
+
raise OptionParser::InvalidArgument, "#{field}=#{value}"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def table(headers, rows)
|
|
365
|
+
widths = headers.each_index.map { |i| ([headers[i]] + rows.map { |r| r[i].to_s }).map(&:length).max }
|
|
366
|
+
([headers] + rows).each do |row|
|
|
367
|
+
@stdout.puts row.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ").rstrip
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def truncate(text, max)
|
|
372
|
+
text = text.to_s
|
|
373
|
+
text.length > max ? "#{text[0, max - 1]}…" : text
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def out(text)
|
|
377
|
+
@stdout.puts text
|
|
378
|
+
0
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def fail_with(message, code)
|
|
382
|
+
@stderr.puts "error: #{message}"
|
|
383
|
+
code
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def help(command = nil, sub = nil)
|
|
387
|
+
command = "regions" if command == "status"
|
|
388
|
+
return out(usage) unless command
|
|
389
|
+
return fail_with("unknown command #{command}", 2) unless COMMANDS.key?(command)
|
|
390
|
+
return out(parser_for(command).help) unless command == "releases"
|
|
391
|
+
return out(releases_usage) unless sub
|
|
392
|
+
return fail_with("unknown command releases #{sub}", 2) unless RELEASE_COMMANDS.key?(sub)
|
|
393
|
+
|
|
394
|
+
out(parser_for(sub).help)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def releases_usage
|
|
398
|
+
<<~TEXT
|
|
399
|
+
Usage: shiftcare-toolkit releases COMMAND [options]
|
|
400
|
+
|
|
401
|
+
Calls the internal Product Releases API (/internal/product_releases).
|
|
402
|
+
|
|
403
|
+
Commands:
|
|
404
|
+
#{RELEASE_COMMANDS.map { |name, text| " #{name.ljust(9)} #{text}" }.join("\n")}
|
|
405
|
+
|
|
406
|
+
Help for one command: shiftcare-toolkit releases copy --help
|
|
407
|
+
TEXT
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def usage
|
|
411
|
+
<<~TEXT
|
|
412
|
+
Usage: shiftcare-toolkit [--region R] [--verbose] COMMAND [options]
|
|
413
|
+
|
|
414
|
+
Internal ShiftCare tools. Region tokens (au, us, uk, ca or custom) are shared by every command.
|
|
415
|
+
|
|
416
|
+
Commands:
|
|
417
|
+
#{COMMANDS.map { |name, text| " #{name.ljust(9)} #{text}" }.join("\n")}
|
|
418
|
+
help Show help for a command: shiftcare-toolkit help releases copy
|
|
419
|
+
|
|
420
|
+
Global options:
|
|
421
|
+
--region R Region to use (default from config, initially au)
|
|
422
|
+
--verbose Log each request to stderr (tokens are never logged)
|
|
423
|
+
--version Print the version
|
|
424
|
+
|
|
425
|
+
Start with: shiftcare-toolkit setup
|
|
426
|
+
TEXT
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# ---- option parsers ----
|
|
430
|
+
|
|
431
|
+
def parser_for(command)
|
|
432
|
+
OptionParser.new do |o|
|
|
433
|
+
o.program_name = "shiftcare-toolkit"
|
|
434
|
+
o.require_exact = true # so --token is not read as --token-stdin
|
|
435
|
+
send("options_#{command}", o)
|
|
436
|
+
o.separator ""
|
|
437
|
+
o.separator "Common options:"
|
|
438
|
+
o.on("--region R", "Region (au, us, uk, ca or custom; default #{default_region_label})") { |v| @opts[:region] = v }
|
|
439
|
+
o.on("--verbose", "Log each request to stderr") { @opts[:verbose] = true }
|
|
440
|
+
o.on("-h", "--help", "Show this help") { @opts[:help] = true }
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def default_region_label
|
|
445
|
+
config.default_region
|
|
446
|
+
rescue StandardError
|
|
447
|
+
"au"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def options_setup(o)
|
|
451
|
+
o.banner = "Usage: shiftcare-toolkit setup [--region R (--token-stdin | --url URL)]"
|
|
452
|
+
o.separator <<~TEXT
|
|
453
|
+
|
|
454
|
+
Saves one token per region to #{Config.default_path} (mode 0600), after
|
|
455
|
+
checking it against the API. With no --region it walks au, us, uk and ca and asks
|
|
456
|
+
for each token with hidden input; press Enter to skip a region.
|
|
457
|
+
|
|
458
|
+
A token works only in the region that issued it. Create one in each region's
|
|
459
|
+
admin console: Admin -> Admin API tokens, scope product_releases:write.
|
|
460
|
+
|
|
461
|
+
Examples:
|
|
462
|
+
shiftcare-toolkit setup
|
|
463
|
+
pbpaste | shiftcare-toolkit setup --region us --token-stdin
|
|
464
|
+
shiftcare-toolkit setup --region staging --url https://staging.example.com
|
|
465
|
+
shiftcare-toolkit setup --region staging --token-stdin < token.txt
|
|
466
|
+
|
|
467
|
+
Options:
|
|
468
|
+
TEXT
|
|
469
|
+
o.on("--token-stdin", "Read the token from stdin (tokens are never taken as arguments)") { @opts[:token_stdin] = true }
|
|
470
|
+
o.on("--url URL", "Base URL, for a custom region such as staging or local") { |v| @opts[:url] = v }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def options_regions(o)
|
|
474
|
+
o.banner = "Usage: shiftcare-toolkit regions"
|
|
475
|
+
o.separator <<~TEXT
|
|
476
|
+
|
|
477
|
+
Lists every region with its base URL, masked token and where it came from
|
|
478
|
+
(config or env), and a live check: ok, 401, 403 or unreachable.
|
|
479
|
+
|
|
480
|
+
Example:
|
|
481
|
+
shiftcare-toolkit regions
|
|
482
|
+
TEXT
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def options_list(o)
|
|
486
|
+
o.banner = "Usage: shiftcare-toolkit releases list [--status S] [--visibility V] [--page N | --all] [--json]"
|
|
487
|
+
o.separator <<~TEXT
|
|
488
|
+
|
|
489
|
+
Examples:
|
|
490
|
+
shiftcare-toolkit releases list
|
|
491
|
+
shiftcare-toolkit releases list --region us --status dev --visibility hidden
|
|
492
|
+
shiftcare-toolkit releases list --all --json > au.json
|
|
493
|
+
|
|
494
|
+
Options:
|
|
495
|
+
TEXT
|
|
496
|
+
o.on("--status S", "Filter by status, e.g. dev, beta, live") { |v| @opts[:status] = v }
|
|
497
|
+
o.on("--visibility V", "Filter by visibility, e.g. hidden, public") { |v| @opts[:visibility] = v }
|
|
498
|
+
o.on("--page N", Integer, "Page to fetch (50 per page)") { |v| @opts[:page] = v }
|
|
499
|
+
o.on("--all", "Follow pagination and fetch every page") { @opts[:all] = true }
|
|
500
|
+
o.on("--json", "Print JSON instead of a table") { @opts[:json] = true }
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def options_show(o)
|
|
504
|
+
o.banner = "Usage: shiftcare-toolkit releases show KEY [--json]"
|
|
505
|
+
o.separator <<~TEXT
|
|
506
|
+
|
|
507
|
+
Examples:
|
|
508
|
+
shiftcare-toolkit releases show shift_swaps
|
|
509
|
+
shiftcare-toolkit releases show shift_swaps --region uk --json
|
|
510
|
+
|
|
511
|
+
Options:
|
|
512
|
+
TEXT
|
|
513
|
+
o.on("--json", "Print JSON") { @opts[:json] = true }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def options_create(o)
|
|
517
|
+
o.banner = "Usage: shiftcare-toolkit releases create --file FILE"
|
|
518
|
+
o.separator <<~TEXT
|
|
519
|
+
|
|
520
|
+
Creates a release from a JSON object holding "key" plus writable fields:
|
|
521
|
+
#{Client::WRITABLE.join(", ")}.
|
|
522
|
+
New releases are always status dev and visibility hidden. 409 if the key exists.
|
|
523
|
+
|
|
524
|
+
Examples:
|
|
525
|
+
shiftcare-toolkit releases create --region au --file release.json
|
|
526
|
+
shiftcare-toolkit releases show shift_swaps --json | shiftcare-toolkit releases create --region us --file -
|
|
527
|
+
|
|
528
|
+
Options:
|
|
529
|
+
TEXT
|
|
530
|
+
o.on("--file FILE", "JSON file, or - for stdin") { |v| @opts[:file] = v }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def options_update(o)
|
|
534
|
+
o.banner = "Usage: shiftcare-toolkit releases update KEY (--file FILE | --set field=value ...)"
|
|
535
|
+
o.separator <<~TEXT
|
|
536
|
+
|
|
537
|
+
Updates writable fields of a hidden dev draft. 409 once it is published or visible.
|
|
538
|
+
Array fields (categories, change_items, flag_keys) take one item per --set, or a
|
|
539
|
+
JSON array. links takes a JSON array of {"label","url"}.
|
|
540
|
+
|
|
541
|
+
Examples:
|
|
542
|
+
shiftcare-toolkit releases update shift_swaps --set title="Shift swaps v2"
|
|
543
|
+
shiftcare-toolkit releases update shift_swaps --set change_items="Swap a shift" --set change_items="Approve a swap"
|
|
544
|
+
shiftcare-toolkit releases update shift_swaps --set 'links=[{"label":"Help","url":"https://help.shiftcare.com"}]'
|
|
545
|
+
shiftcare-toolkit releases update shift_swaps --region us --file patch.json
|
|
546
|
+
|
|
547
|
+
Options:
|
|
548
|
+
TEXT
|
|
549
|
+
o.on("--file FILE", "JSON file with fields to change, or - for stdin") { |v| @opts[:file] = v }
|
|
550
|
+
o.on("--set FIELD=VALUE", "Set one field (repeatable)") { |v| (@opts[:set] ||= []) << parse_set(v) }
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def options_copy(o)
|
|
554
|
+
o.banner = "Usage: shiftcare-toolkit releases copy --from R --to R1,R2 [--key K ...] [--update-existing] [--dry-run]"
|
|
555
|
+
o.separator <<~TEXT
|
|
556
|
+
|
|
557
|
+
Copies releases (key + writable fields) from one region to others. Missing keys are
|
|
558
|
+
created as hidden dev drafts. Existing keys are skipped unless --update-existing,
|
|
559
|
+
which updates them; a target that is no longer a hidden draft (409) is reported and
|
|
560
|
+
skipped. Ends with a created/updated/skipped/failed summary per region and exits 1
|
|
561
|
+
if anything failed.
|
|
562
|
+
|
|
563
|
+
Examples:
|
|
564
|
+
shiftcare-toolkit releases copy --from au --to us,uk,ca --dry-run
|
|
565
|
+
shiftcare-toolkit releases copy --from au --to us,uk,ca --key shift_swaps --key rosters_v2
|
|
566
|
+
shiftcare-toolkit releases copy --from au --to us --update-existing
|
|
567
|
+
|
|
568
|
+
Options:
|
|
569
|
+
TEXT
|
|
570
|
+
o.on("--from R", "Source region") { |v| @opts[:from] = v }
|
|
571
|
+
o.on("--to R1,R2", Array, "Target regions, comma separated") { |v| @opts[:to] = v }
|
|
572
|
+
o.on("--key K", "Copy only this key (repeatable; default: every release)") { |v| (@opts[:keys] ||= []) << v }
|
|
573
|
+
o.on("--update-existing", "Update keys that already exist in the target") { @opts[:update_existing] = true }
|
|
574
|
+
o.on("--dry-run", "Show what would change without writing") { @opts[:dry_run] = true }
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module ShiftcareToolkit
|
|
10
|
+
# One region's /internal/product_releases API. `http` is any callable taking
|
|
11
|
+
# (uri, request) and returning something with #code, #body and #[] (header),
|
|
12
|
+
# so tests can pass a fake instead of Net::HTTP.
|
|
13
|
+
class Client
|
|
14
|
+
WRITABLE = %w[
|
|
15
|
+
title summary rich_body owner icon tone help_url video_url video_title
|
|
16
|
+
video_duration_seconds categories change_items flag_keys links
|
|
17
|
+
].freeze
|
|
18
|
+
MAX_RETRIES = 5
|
|
19
|
+
NETWORK_ERRORS = [
|
|
20
|
+
SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
class Error < StandardError
|
|
24
|
+
attr_reader :status
|
|
25
|
+
|
|
26
|
+
def initialize(message, status: nil)
|
|
27
|
+
super(message)
|
|
28
|
+
@status = status
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# min_interval keeps one client under the 60 req/min per-token limit.
|
|
33
|
+
def initialize(base_url:, token:, region: nil, http: nil, min_interval: 1.0, sleeper: ->(s) { sleep(s) }, log: nil)
|
|
34
|
+
@base_url = base_url.chomp("/")
|
|
35
|
+
@token = token
|
|
36
|
+
@region = region || base_url
|
|
37
|
+
@http = http || method(:net_http)
|
|
38
|
+
@min_interval = min_interval
|
|
39
|
+
@sleeper = sleeper
|
|
40
|
+
@log = log
|
|
41
|
+
@last_request_at = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def list(page: 1, status: nil, visibility: nil)
|
|
45
|
+
query = { page: page, status: status, visibility: visibility }.compact
|
|
46
|
+
request(:get, "/internal/product_releases?#{URI.encode_www_form(query)}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def list_all(status: nil, visibility: nil)
|
|
50
|
+
releases = []
|
|
51
|
+
(1..).each do |page|
|
|
52
|
+
body = list(page: page, status: status, visibility: visibility)
|
|
53
|
+
releases.concat(body["data"])
|
|
54
|
+
meta = body["meta"]
|
|
55
|
+
break if body["data"].empty? || page * meta["per_page"] >= meta["total"]
|
|
56
|
+
end
|
|
57
|
+
releases
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def get(key)
|
|
61
|
+
request(:get, path_for(key))["data"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create(attrs)
|
|
65
|
+
request(:post, "/internal/product_releases", { product_release: attrs })["data"]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def update(key, attrs)
|
|
69
|
+
request(:patch, path_for(key), { product_release: attrs })["data"]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def path_for(key)
|
|
75
|
+
"/internal/product_releases/#{URI.encode_www_form_component(key)}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def request(verb, path, payload = nil)
|
|
79
|
+
raise Error, "no token for #{@region}: run `shiftcare-toolkit setup --region #{@region}`" if @token.to_s.empty?
|
|
80
|
+
|
|
81
|
+
uri = URI("#{@base_url}#{path}")
|
|
82
|
+
attempt = 0
|
|
83
|
+
loop do
|
|
84
|
+
response = perform(verb, uri, payload)
|
|
85
|
+
code = response.code.to_i
|
|
86
|
+
if code == 429 && attempt < MAX_RETRIES
|
|
87
|
+
attempt += 1
|
|
88
|
+
wait = retry_after(response, attempt)
|
|
89
|
+
@log&.call("#{@region}: rate limited, retry #{attempt}/#{MAX_RETRIES} in #{wait}s")
|
|
90
|
+
@sleeper.call(wait)
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
return parse(response.body) if code.between?(200, 299)
|
|
94
|
+
|
|
95
|
+
raise Error.new(error_message(code, response.body), status: code)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def perform(verb, uri, payload)
|
|
100
|
+
throttle
|
|
101
|
+
req = Net::HTTP.const_get(verb.capitalize).new(uri)
|
|
102
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
103
|
+
req["User-Agent"] = "shiftcare-toolkit/#{VERSION}"
|
|
104
|
+
req["Accept"] = "application/json"
|
|
105
|
+
if payload
|
|
106
|
+
req["Content-Type"] = "application/json"
|
|
107
|
+
req.body = JSON.generate(payload)
|
|
108
|
+
end
|
|
109
|
+
started = Time.now
|
|
110
|
+
response = @http.call(uri, req)
|
|
111
|
+
@log&.call("#{verb.upcase} #{uri} -> #{response.code} (#{((Time.now - started) * 1000).round}ms)")
|
|
112
|
+
response
|
|
113
|
+
rescue *NETWORK_ERRORS => e
|
|
114
|
+
raise Error, "#{@region} unreachable (#{uri.host}): #{e.class}: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def throttle
|
|
118
|
+
if @last_request_at && @min_interval.positive?
|
|
119
|
+
wait = @min_interval - (Time.now - @last_request_at)
|
|
120
|
+
@sleeper.call(wait) if wait.positive?
|
|
121
|
+
end
|
|
122
|
+
@last_request_at = Time.now
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The API sends no Retry-After today; fall back to 5, 10, 20, 40, 60 s.
|
|
126
|
+
def retry_after(response, attempt)
|
|
127
|
+
header = response["Retry-After"].to_s
|
|
128
|
+
header.match?(/\A\d+\z/) ? header.to_i : [5 * (2**(attempt - 1)), 60].min
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse(body)
|
|
132
|
+
body.to_s.empty? ? {} : JSON.parse(body)
|
|
133
|
+
rescue JSON::ParserError
|
|
134
|
+
raise Error, "#{@region}: response was not JSON"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def error_message(code, body)
|
|
138
|
+
detail = begin
|
|
139
|
+
json = JSON.parse(body.to_s)
|
|
140
|
+
json["error"] || Array(json["errors"]).join("; ")
|
|
141
|
+
rescue JSON::ParserError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
case code
|
|
145
|
+
when 401 then "#{@region}: 401 token rejected (bad, expired or revoked). Tokens work only in the region that issued them."
|
|
146
|
+
when 403
|
|
147
|
+
if detail.nil? && body.to_s.include?("1010")
|
|
148
|
+
"#{@region}: 403 blocked by Cloudflare (error 1010)"
|
|
149
|
+
else
|
|
150
|
+
"#{@region}: 403 #{detail || "forbidden"} (the token needs the product_releases:write scope)"
|
|
151
|
+
end
|
|
152
|
+
when 429 then "#{@region}: 429 still rate limited after #{MAX_RETRIES} retries"
|
|
153
|
+
else "#{@region}: #{code} #{detail.to_s.empty? ? "request failed" : detail}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def net_http(uri, req)
|
|
158
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
|
|
159
|
+
http.request(req)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module ShiftcareToolkit
|
|
7
|
+
# Per-region base URLs and tokens, stored in a 0600 YAML file:
|
|
8
|
+
#
|
|
9
|
+
# default_region: au
|
|
10
|
+
# regions:
|
|
11
|
+
# au: { token: scadm_... }
|
|
12
|
+
# staging: { url: https://staging.example.com, token: scadm_... }
|
|
13
|
+
class Config
|
|
14
|
+
REGIONS = {
|
|
15
|
+
"au" => "https://app.shiftcare.com",
|
|
16
|
+
"us" => "https://us.shiftcare.com",
|
|
17
|
+
"uk" => "https://uk.shiftcare.com",
|
|
18
|
+
"ca" => "https://ca.shiftcare.com",
|
|
19
|
+
}.freeze
|
|
20
|
+
REGION_FORMAT = /\A[a-z0-9_-]+\z/
|
|
21
|
+
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
attr_reader :path
|
|
25
|
+
|
|
26
|
+
def self.default_path(env = ENV)
|
|
27
|
+
base = env["XDG_CONFIG_HOME"].to_s.empty? ? File.join(Dir.home, ".config") : env["XDG_CONFIG_HOME"]
|
|
28
|
+
File.join(base, "shiftcare-toolkit", "config.yml")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Shows the prefix only, the part the admin console also shows.
|
|
32
|
+
def self.mask(token)
|
|
33
|
+
return "-" if token.to_s.empty?
|
|
34
|
+
|
|
35
|
+
"#{token[0, [12, token.length / 3].min]}…"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(path = self.class.default_path, env: ENV)
|
|
39
|
+
@path = path
|
|
40
|
+
@env = env
|
|
41
|
+
@data = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
42
|
+
@data["regions"] ||= {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def default_region
|
|
46
|
+
@data["default_region"] || "au"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Built-in regions first, then custom ones from the file.
|
|
50
|
+
def regions
|
|
51
|
+
(REGIONS.keys + @data["regions"].keys).uniq
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def url(region)
|
|
55
|
+
url = @data.dig("regions", region, "url") || REGIONS[region]
|
|
56
|
+
raise Error, "unknown region #{region}: run `shiftcare-toolkit setup --region #{region} --url URL`" unless url
|
|
57
|
+
|
|
58
|
+
url.chomp("/")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def token(region)
|
|
62
|
+
env_token(region) || @data.dig("regions", region, "token")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def token_source(region)
|
|
66
|
+
return "env" if env_token(region)
|
|
67
|
+
|
|
68
|
+
@data.dig("regions", region, "token") ? "config" : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def set(region, url: nil, token: nil)
|
|
72
|
+
raise Error, "bad region name #{region.inspect}: use lowercase letters, digits, - or _" unless region.match?(REGION_FORMAT)
|
|
73
|
+
|
|
74
|
+
entry = (@data["regions"][region] ||= {})
|
|
75
|
+
entry["url"] = url.chomp("/") if url
|
|
76
|
+
entry["token"] = token if token
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def save
|
|
80
|
+
dir = File.dirname(path)
|
|
81
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
82
|
+
File.chmod(0o700, dir)
|
|
83
|
+
tmp = "#{path}.tmp"
|
|
84
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(YAML.dump(@data)) }
|
|
85
|
+
File.chmod(0o600, tmp)
|
|
86
|
+
File.rename(tmp, path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def env_token(region)
|
|
92
|
+
value = @env["SHIFTCARE_TOOLKIT_TOKEN_#{region.upcase.tr("-", "_")}"]
|
|
93
|
+
value unless value.to_s.empty?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shiftcare-toolkit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ShiftCare
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: 'Internal ShiftCare tools. First tool: list, show, create, update and
|
|
14
|
+
copy product releases across the au, us, uk and ca regions.'
|
|
15
|
+
email:
|
|
16
|
+
executables:
|
|
17
|
+
- shiftcare-toolkit
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- README.md
|
|
23
|
+
- exe/shiftcare-toolkit
|
|
24
|
+
- lib/shiftcare_toolkit.rb
|
|
25
|
+
- lib/shiftcare_toolkit/cli.rb
|
|
26
|
+
- lib/shiftcare_toolkit/client.rb
|
|
27
|
+
- lib/shiftcare_toolkit/config.rb
|
|
28
|
+
- lib/shiftcare_toolkit/version.rb
|
|
29
|
+
homepage: https://github.com/shiftcare/shiftcare-toolkit
|
|
30
|
+
licenses:
|
|
31
|
+
- Nonstandard
|
|
32
|
+
metadata:
|
|
33
|
+
allowed_push_host: https://rubygems.org
|
|
34
|
+
source_code_uri: https://github.com/shiftcare/shiftcare-toolkit
|
|
35
|
+
changelog_uri: https://github.com/shiftcare/shiftcare-toolkit/blob/main/CHANGELOG.md
|
|
36
|
+
post_install_message:
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '3.1'
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 3.4.19
|
|
52
|
+
signing_key:
|
|
53
|
+
specification_version: 4
|
|
54
|
+
summary: Internal ShiftCare command-line tools
|
|
55
|
+
test_files: []
|