bugsink-cli 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bc4e718e40bae09f46946050bfd5a52133b1a7f1b4653bd5d74ac4b39eeeef44
4
+ data.tar.gz: 04bc34a4dabc823fd3fd262b1b28e7174407e0cb922d5d6bd8e34ca526f0dedb
5
+ SHA512:
6
+ metadata.gz: a4af07cd5b996e3ded9cd7333a1f1f097f6c6720a3b41b4cea21e03b6631c53c08642122e1e78d5cfdd1706d4b462b8ec03e8b7a366942ff5456774b07178f2b
7
+ data.tar.gz: 5c1303e396df707697e9fa08331720031cc39630367b4fbb0672769da9017cb0f9add4b0a38dbb9466d5e09c1b818a5640f008fa04266095e045a4263d600db6
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-02-04
9
+
10
+ ### Added
11
+ - Initial release of bugsink-cli gem
12
+ - Complete BugSink API wrapper with support for:
13
+ - Teams (list, get, create, update)
14
+ - Projects (list, get, create, update)
15
+ - Issues (list, get) - read-only
16
+ - Events (list, get, stacktrace) - read-only
17
+ - Releases (list, get, create)
18
+ - Configuration management via environment variables and .bugsink dotfile
19
+ - Multiple output formats: table, JSON, quiet
20
+ - Comprehensive CLI with help system
21
+ - HTTParty-based HTTP client with error handling
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tomáš Landovský
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,320 @@
1
+ # BugSink CLI
2
+
3
+ A command-line interface for the BugSink error tracking service. Provides full API access for teams, projects, issues, events, and releases.
4
+
5
+ ## Installation
6
+
7
+ Install the gem:
8
+
9
+ ```bash
10
+ gem install bugsink-cli
11
+ ```
12
+
13
+ Or add to your Gemfile:
14
+
15
+ ```ruby
16
+ gem 'bugsink-cli', '~> 0.1.0'
17
+ ```
18
+
19
+ ## Configuration
20
+
21
+ ### Environment Variables
22
+
23
+ **Required:**
24
+ - `BUGSINK_API_KEY` - Your BugSink API authentication token
25
+
26
+ **Optional:**
27
+ - `BUGSINK_HOST` - API host (default: `https://bugs.kopernici.cz`)
28
+
29
+ ### Project Configuration
30
+
31
+ The CLI supports a `.bugsink` dotfile in your project root to store the default project ID:
32
+
33
+ ```bash
34
+ # Set default project
35
+ bugsink config set-project 8
36
+
37
+ # This creates .bugsink file with:
38
+ PROJECT_ID=8
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```bash
44
+ # Set your API key
45
+ export BUGSINK_API_KEY="your-token-here"
46
+
47
+ # Test connection
48
+ bugsink config test
49
+
50
+ # Set default project
51
+ bugsink config set-project 8
52
+
53
+ # List latest issues
54
+ bugsink issues list --project=8 --sort=last_seen --order=desc
55
+
56
+ # Get formatted stacktrace
57
+ bugsink events stacktrace <event-uuid>
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ### General Syntax
63
+
64
+ ```bash
65
+ bugsink <resource> <action> [arguments] [options]
66
+ ```
67
+
68
+ ### Global Options
69
+
70
+ - `--json` - Output as JSON (machine-readable)
71
+ - `--quiet` - Minimal output (IDs only)
72
+
73
+ ### Resources
74
+
75
+ #### Config
76
+
77
+ ```bash
78
+ bugsink config show # Show current configuration
79
+ bugsink config set-project <id> # Set default project ID
80
+ bugsink config test # Test API connectivity
81
+ ```
82
+
83
+ #### Teams
84
+
85
+ ```bash
86
+ # List all teams
87
+ bugsink teams list [--json|--quiet]
88
+
89
+ # Get team details
90
+ bugsink teams get <uuid> [--json]
91
+
92
+ # Create team
93
+ bugsink teams create '{"name":"Team Name","visibility":"hidden"}'
94
+
95
+ # Update team
96
+ bugsink teams update <uuid> '{"name":"New Name"}'
97
+ ```
98
+
99
+ #### Projects
100
+
101
+ ```bash
102
+ # List all projects (optionally filter by team)
103
+ bugsink projects list [--team=<uuid>] [--json|--quiet]
104
+
105
+ # Get project details
106
+ bugsink projects get <id> [--json]
107
+
108
+ # Create project
109
+ bugsink projects create '{
110
+ "team":"<team-uuid>",
111
+ "name":"Project Name",
112
+ "visibility":"team_members",
113
+ "alert_on_new_issue":true
114
+ }'
115
+
116
+ # Update project
117
+ bugsink projects update <id> '{"name":"New Name","alert_on_new_issue":false}'
118
+ ```
119
+
120
+ #### Issues (Read-Only)
121
+
122
+ ```bash
123
+ # List issues for a project
124
+ bugsink issues list --project=<id> \
125
+ [--sort=last_seen|digest_order] \
126
+ [--order=asc|desc] \
127
+ [--json|--quiet]
128
+
129
+ # Get issue details
130
+ bugsink issues get <uuid> [--json]
131
+ ```
132
+
133
+ **Note:** Issues are **read-only** via the API. You cannot update status, resolve, or add comments through the CLI.
134
+
135
+ #### Events (Read-Only)
136
+
137
+ ```bash
138
+ # List events for an issue
139
+ bugsink events list --issue=<uuid> [--order=asc|desc] [--json|--quiet]
140
+
141
+ # Get event details
142
+ bugsink events get <uuid> [--json]
143
+
144
+ # Get formatted stacktrace (Markdown)
145
+ bugsink events stacktrace <uuid>
146
+ ```
147
+
148
+ **Note:** Events are **read-only** and created automatically via Sentry SDK. No manual creation/updates available.
149
+
150
+ #### Releases
151
+
152
+ ```bash
153
+ # List releases for a project
154
+ bugsink releases list --project=<id> [--json|--quiet]
155
+
156
+ # Get release details
157
+ bugsink releases get <uuid> [--json]
158
+
159
+ # Create release
160
+ bugsink releases create '{
161
+ "project":8,
162
+ "version":"v1.2.3",
163
+ "timestamp":"2026-02-03T12:00:00Z"
164
+ }'
165
+ ```
166
+
167
+ **Note:** Releases can be created but not updated or deleted.
168
+
169
+ ### Help System
170
+
171
+ ```bash
172
+ # General help
173
+ bugsink help
174
+
175
+ # Resource-specific help
176
+ bugsink help teams
177
+ bugsink help projects
178
+ bugsink help issues
179
+ bugsink help events
180
+ bugsink help releases
181
+ ```
182
+
183
+ ## Examples
184
+
185
+ ### Daily Development Workflow
186
+
187
+ ```bash
188
+ # Set up once
189
+ export BUGSINK_API_KEY="..."
190
+ cd /path/to/project
191
+ bugsink config set-project 8
192
+
193
+ # Check latest errors
194
+ bugsink issues list --project=8 --sort=last_seen --order=desc --json | jq '.[:5]'
195
+
196
+ # Get detailed stacktrace for an issue
197
+ ISSUE_UUID=$(bugsink issues list --project=8 --quiet | head -1)
198
+ EVENT_UUID=$(bugsink events list --issue=$ISSUE_UUID --quiet | head -1)
199
+ bugsink events stacktrace $EVENT_UUID
200
+ ```
201
+
202
+ ### Creating a Release
203
+
204
+ ```bash
205
+ # After deployment
206
+ VERSION="v$(date +%Y%m%d-%H%M%S)"
207
+ bugsink releases create "{\"project\":8,\"version\":\"$VERSION\"}"
208
+ ```
209
+
210
+ ### Filtering and Searching
211
+
212
+ ```bash
213
+ # Get all projects for a specific team
214
+ TEAM_UUID="ee4f4572-0957-4346-b433-3c605acbfa2a"
215
+ bugsink projects list --team=$TEAM_UUID --json | jq '.[].name'
216
+
217
+ # Get IDs of all teams
218
+ bugsink teams list --quiet
219
+ ```
220
+
221
+ ## Output Formats
222
+
223
+ ### Table (Default)
224
+
225
+ Human-readable table format with tab-separated columns:
226
+
227
+ ```
228
+ id name visibility
229
+ ee4f4572... Team Name team_members
230
+ ```
231
+
232
+ ### JSON
233
+
234
+ Machine-readable JSON format (use `--json`):
235
+
236
+ ```json
237
+ [
238
+ {
239
+ "id": "ee4f4572-0957-4346-b433-3c605acbfa2a",
240
+ "name": "Team Name",
241
+ "visibility": "team_members"
242
+ }
243
+ ]
244
+ ```
245
+
246
+ ### Quiet
247
+
248
+ Minimal output with just IDs (use `--quiet`):
249
+
250
+ ```
251
+ ee4f4572-0957-4346-b433-3c605acbfa2a
252
+ ```
253
+
254
+ ## API Capabilities & Limitations
255
+
256
+ ### Supported Write Operations
257
+
258
+ - **Teams:** Create, Update
259
+ - **Projects:** Create, Update
260
+ - **Releases:** Create
261
+
262
+ ### Read-Only Resources
263
+
264
+ - **Issues:** Cannot update status, resolve, or add comments
265
+ - **Events:** Created automatically via Sentry SDK ingestion only
266
+
267
+ ### Not Supported
268
+
269
+ - **DELETE operations:** No resource can be deleted via API
270
+ - **Bulk operations:** Must process items one at a time
271
+ - **Filtering on list endpoints:** Limited filter support
272
+
273
+ ## Library Usage
274
+
275
+ You can also use bugsink-cli as a Ruby library:
276
+
277
+ ```ruby
278
+ require 'bugsink'
279
+
280
+ # Initialize client
281
+ config = Bugsink::Config.new
282
+ client = Bugsink::Client.new(config)
283
+
284
+ # List teams
285
+ response = client.teams_list
286
+ teams = response.data
287
+
288
+ # Get project
289
+ project = client.project_get(8)
290
+
291
+ # Create release
292
+ release = client.release_create(
293
+ project_id: 8,
294
+ version: 'v1.0.0'
295
+ )
296
+ ```
297
+
298
+ ## Development
299
+
300
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests.
301
+
302
+ To install this gem onto your local machine, run `bundle exec rake install`.
303
+
304
+ ### Running Tests
305
+
306
+ ```bash
307
+ # Run all tests
308
+ BUGSINK_API_KEY="your-key" bundle exec rspec
309
+
310
+ # Run specific test
311
+ BUGSINK_API_KEY="your-key" bundle exec rspec spec/bugsink/config_spec.rb
312
+ ```
313
+
314
+ ## Contributing
315
+
316
+ Bug reports and pull requests are welcome on GitHub at https://github.com/koperniki/bugsink-cli.
317
+
318
+ ## License
319
+
320
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/bugsink ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bugsink'
5
+
6
+ cli = Bugsink::CLI.new(ARGV)
7
+ cli.run
@@ -0,0 +1,573 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'json'
5
+ require_relative 'client'
6
+ require_relative 'config'
7
+
8
+ module Bugsink
9
+ # CLI command parser and router for BugSink API
10
+ class CLI
11
+ attr_reader :client, :config, :options
12
+
13
+ def initialize(args = ARGV)
14
+ @args = args
15
+ @options = {
16
+ format: 'table',
17
+ project_id: nil
18
+ }
19
+ @config = Config.new
20
+ @client = Client.new(@config)
21
+ rescue Config::ConfigError => e
22
+ error("Configuration error: #{e.message}")
23
+ exit 1
24
+ end
25
+
26
+ def run
27
+ return show_help if @args.empty?
28
+
29
+ resource = @args[0]
30
+ action = @args[1]
31
+
32
+ case resource
33
+ when 'help', '--help', '-h'
34
+ show_help(action)
35
+ when '--version', '-v'
36
+ show_version
37
+ when 'config'
38
+ handle_config(action)
39
+ when 'teams'
40
+ handle_teams(action, @args[2..])
41
+ when 'projects'
42
+ handle_projects(action, @args[2..])
43
+ when 'issues'
44
+ handle_issues(action, @args[2..])
45
+ when 'events'
46
+ handle_events(action, @args[2..])
47
+ when 'releases'
48
+ handle_releases(action, @args[2..])
49
+ else
50
+ error("Unknown resource: #{resource}")
51
+ show_help
52
+ exit 1
53
+ end
54
+ rescue Client::ClientError => e
55
+ error("API error: #{e.message}")
56
+ exit 1
57
+ rescue ArgumentError => e
58
+ error("Argument error: #{e.message}")
59
+ exit 1
60
+ rescue StandardError => e
61
+ error("Unexpected error: #{e.class} - #{e.message}")
62
+ error(e.backtrace.join("\n")) if ENV['DEBUG']
63
+ exit 1
64
+ end
65
+
66
+ private
67
+
68
+ def handle_config(action)
69
+ case action
70
+ when 'show'
71
+ puts @config.to_s
72
+ when 'set-project'
73
+ project_id = @args[2]
74
+ error('Project ID required') && exit(1) unless project_id
75
+ @config.set_project_id(project_id.to_i)
76
+ success("Project ID set to #{project_id}")
77
+ when 'test'
78
+ @client.test_connection
79
+ success('API connection successful!')
80
+ else
81
+ error("Unknown config action: #{action}")
82
+ exit 1
83
+ end
84
+ end
85
+
86
+ def handle_teams(action, args)
87
+ case action
88
+ when 'list'
89
+ parse_format_options!(args)
90
+ response = @client.teams_list
91
+ output_list(response.data, format: @options[:format])
92
+ when 'get'
93
+ uuid = args[0]
94
+ error('Team UUID required') && exit(1) unless uuid
95
+ parse_format_options!(args[1..])
96
+ team = @client.team_get(uuid)
97
+ output_single(team, format: @options[:format])
98
+ when 'create'
99
+ data = parse_json_arg(args[0])
100
+ error('Name required in JSON') && exit(1) unless data['name']
101
+ team = @client.team_create(
102
+ name: data['name'],
103
+ visibility: data['visibility']
104
+ )
105
+ output_single(team, format: 'json')
106
+ when 'update'
107
+ uuid = args[0]
108
+ error('Team UUID required') && exit(1) unless uuid
109
+ data = parse_json_arg(args[1])
110
+ error('Update data required') && exit(1) if data.empty?
111
+ team = @client.team_update(
112
+ uuid,
113
+ name: data['name'],
114
+ visibility: data['visibility']
115
+ )
116
+ output_single(team, format: 'json')
117
+ else
118
+ error("Unknown teams action: #{action}")
119
+ exit 1
120
+ end
121
+ end
122
+
123
+ def handle_projects(action, args)
124
+ case action
125
+ when 'list'
126
+ parse_project_options!(args)
127
+ response = @client.projects_list(team_uuid: @options[:team])
128
+ output_list(response.data, format: @options[:format])
129
+ when 'get'
130
+ id = args[0]
131
+ error('Project ID required') && exit(1) unless id
132
+ parse_format_options!(args[1..])
133
+ project = @client.project_get(id.to_i)
134
+ output_single(project, format: @options[:format])
135
+ when 'create'
136
+ data = parse_json_arg(args[0])
137
+ error('Team and name required in JSON') && exit(1) unless data['team'] && data['name']
138
+ project = @client.project_create(
139
+ team_uuid: data['team'],
140
+ name: data['name'],
141
+ visibility: data['visibility'],
142
+ alert_on_new_issue: data['alert_on_new_issue'],
143
+ alert_on_regression: data['alert_on_regression'],
144
+ alert_on_unmute: data['alert_on_unmute']
145
+ )
146
+ output_single(project, format: 'json')
147
+ when 'update'
148
+ id = args[0]
149
+ error('Project ID required') && exit(1) unless id
150
+ data = parse_json_arg(args[1])
151
+ error('Update data required') && exit(1) if data.empty?
152
+ project = @client.project_update(
153
+ id.to_i,
154
+ name: data['name'],
155
+ visibility: data['visibility'],
156
+ alert_on_new_issue: data['alert_on_new_issue'],
157
+ alert_on_regression: data['alert_on_regression'],
158
+ alert_on_unmute: data['alert_on_unmute']
159
+ )
160
+ output_single(project, format: 'json')
161
+ else
162
+ error("Unknown projects action: #{action}")
163
+ exit 1
164
+ end
165
+ end
166
+
167
+ def handle_issues(action, args)
168
+ case action
169
+ when 'list'
170
+ parse_issue_options!(args)
171
+ project_id = @options[:project_id] || @config.project_id
172
+ error('Project ID required (use --project or set via config)') && exit(1) unless project_id
173
+ response = @client.issues_list(
174
+ project_id: project_id,
175
+ sort: @options[:sort] || 'last_seen',
176
+ order: @options[:order] || 'desc'
177
+ )
178
+ output_list(response.data, format: @options[:format])
179
+ when 'get'
180
+ uuid = args[0]
181
+ error('Issue UUID required') && exit(1) unless uuid
182
+ parse_format_options!(args[1..])
183
+ issue = @client.issue_get(uuid)
184
+ output_single(issue, format: @options[:format])
185
+ else
186
+ error("Unknown issues action: #{action}")
187
+ info('Note: Issues are read-only. Write operations not available in API.')
188
+ exit 1
189
+ end
190
+ end
191
+
192
+ def handle_events(action, args)
193
+ case action
194
+ when 'list'
195
+ parse_event_options!(args)
196
+ error('Issue UUID required (use --issue)') && exit(1) unless @options[:issue]
197
+ response = @client.events_list(
198
+ issue_uuid: @options[:issue],
199
+ order: @options[:order] || 'desc'
200
+ )
201
+ output_list(response.data, format: @options[:format])
202
+ when 'get'
203
+ uuid = args[0]
204
+ error('Event UUID required') && exit(1) unless uuid
205
+ parse_format_options!(args[1..])
206
+ event = @client.event_get(uuid)
207
+ output_single(event, format: @options[:format])
208
+ when 'stacktrace'
209
+ uuid = args[0]
210
+ error('Event UUID required') && exit(1) unless uuid
211
+ stacktrace = @client.event_stacktrace(uuid)
212
+ puts stacktrace
213
+ else
214
+ error("Unknown events action: #{action}")
215
+ exit 1
216
+ end
217
+ end
218
+
219
+ def handle_releases(action, args)
220
+ case action
221
+ when 'list'
222
+ parse_release_options!(args)
223
+ project_id = @options[:project_id] || @config.project_id
224
+ error('Project ID required (use --project or set via config)') && exit(1) unless project_id
225
+ response = @client.releases_list(project_id: project_id)
226
+ output_list(response.data, format: @options[:format])
227
+ when 'get'
228
+ uuid = args[0]
229
+ error('Release UUID required') && exit(1) unless uuid
230
+ parse_format_options!(args[1..])
231
+ release = @client.release_get(uuid)
232
+ output_single(release, format: @options[:format])
233
+ when 'create'
234
+ data = parse_json_arg(args[0])
235
+ error('Project and version required in JSON') && exit(1) unless data['project'] && data['version']
236
+ release = @client.release_create(
237
+ project_id: data['project'],
238
+ version: data['version'],
239
+ timestamp: data['timestamp']
240
+ )
241
+ output_single(release, format: 'json')
242
+ else
243
+ error("Unknown releases action: #{action}")
244
+ exit 1
245
+ end
246
+ end
247
+
248
+ def parse_format_options!(args)
249
+ OptionParser.new do |opts|
250
+ opts.on('--json', 'Output as JSON') { @options[:format] = 'json' }
251
+ opts.on('--quiet', 'Minimal output') { @options[:format] = 'quiet' }
252
+ end.parse!(args)
253
+ end
254
+
255
+ def parse_project_options!(args)
256
+ OptionParser.new do |opts|
257
+ opts.on('--team=UUID', 'Filter by team UUID') { |v| @options[:team] = v }
258
+ opts.on('--json', 'Output as JSON') { @options[:format] = 'json' }
259
+ opts.on('--quiet', 'Minimal output') { @options[:format] = 'quiet' }
260
+ end.parse!(args)
261
+ end
262
+
263
+ def parse_issue_options!(args)
264
+ OptionParser.new do |opts|
265
+ opts.on('--project=ID', 'Project ID') { |v| @options[:project_id] = v.to_i }
266
+ opts.on('--sort=FIELD', 'Sort field') { |v| @options[:sort] = v }
267
+ opts.on('--order=ORDER', 'Sort order (asc|desc)') { |v| @options[:order] = v }
268
+ opts.on('--json', 'Output as JSON') { @options[:format] = 'json' }
269
+ opts.on('--quiet', 'Minimal output') { @options[:format] = 'quiet' }
270
+ end.parse!(args)
271
+ end
272
+
273
+ def parse_event_options!(args)
274
+ OptionParser.new do |opts|
275
+ opts.on('--issue=UUID', 'Issue UUID (required)') { |v| @options[:issue] = v }
276
+ opts.on('--order=ORDER', 'Sort order (asc|desc)') { |v| @options[:order] = v }
277
+ opts.on('--json', 'Output as JSON') { @options[:format] = 'json' }
278
+ opts.on('--quiet', 'Minimal output') { @options[:format] = 'quiet' }
279
+ end.parse!(args)
280
+ end
281
+
282
+ def parse_release_options!(args)
283
+ OptionParser.new do |opts|
284
+ opts.on('--project=ID', 'Project ID') { |v| @options[:project_id] = v.to_i }
285
+ opts.on('--json', 'Output as JSON') { @options[:format] = 'json' }
286
+ opts.on('--quiet', 'Minimal output') { @options[:format] = 'quiet' }
287
+ end.parse!(args)
288
+ end
289
+
290
+ def parse_json_arg(json_str)
291
+ return {} unless json_str
292
+
293
+ JSON.parse(json_str)
294
+ rescue JSON::ParserError => e
295
+ error("Invalid JSON: #{e.message}")
296
+ exit 1
297
+ end
298
+
299
+ def output_list(data, format:)
300
+ case format
301
+ when 'json'
302
+ puts JSON.pretty_generate(data)
303
+ when 'quiet'
304
+ data.each { |item| puts item['id'] || item['uuid'] }
305
+ else
306
+ output_table(data)
307
+ end
308
+ end
309
+
310
+ def output_single(data, format:)
311
+ case format
312
+ when 'json'
313
+ puts JSON.pretty_generate(data)
314
+ when 'quiet'
315
+ puts data['id'] || data['uuid']
316
+ else
317
+ puts JSON.pretty_generate(data)
318
+ end
319
+ end
320
+
321
+ def output_table(data)
322
+ return puts 'No data' if data.empty?
323
+
324
+ # Extract common fields
325
+ keys = data.first.keys
326
+ headers = keys.join("\t")
327
+ puts headers
328
+ puts '-' * 80
329
+
330
+ data.each do |item|
331
+ values = keys.map { |k| format_value(item[k]) }
332
+ puts values.join("\t")
333
+ end
334
+ end
335
+
336
+ def format_value(value)
337
+ case value
338
+ when Hash, Array
339
+ JSON.generate(value)
340
+ when nil
341
+ ''
342
+ else
343
+ value.to_s
344
+ end
345
+ end
346
+
347
+ def show_version
348
+ puts "BugSink CLI v#{Bugsink::VERSION}"
349
+ end
350
+
351
+ def show_help(resource = nil)
352
+ if resource
353
+ show_resource_help(resource)
354
+ else
355
+ show_general_help
356
+ end
357
+ end
358
+
359
+ def show_general_help
360
+ puts <<~HELP
361
+ BugSink CLI - API wrapper for BugSink error tracking
362
+
363
+ Usage: bugsink <resource> <action> [options]
364
+
365
+ Resources:
366
+ config - Configuration management
367
+ teams - Team operations
368
+ projects - Project operations
369
+ issues - Issue operations (read-only)
370
+ events - Event operations (read-only)
371
+ releases - Release operations
372
+
373
+ Global Options:
374
+ --json Output as JSON
375
+ --quiet Minimal output (IDs only)
376
+
377
+ Common Commands:
378
+ bugsink config show Show current configuration
379
+ bugsink config set-project <id> Set default project ID
380
+ bugsink config test Test API connectivity
381
+
382
+ bugsink teams list List all teams
383
+ bugsink teams get <uuid> Get team details
384
+ bugsink teams create '{"name":"TeamName"}' Create team
385
+
386
+ bugsink projects list [--team=<uuid>] List projects
387
+ bugsink projects get <id> Get project details
388
+
389
+ bugsink issues list --project=<id> List issues for project
390
+ bugsink issues get <uuid> Get issue details
391
+
392
+ bugsink events list --issue=<uuid> List events for issue
393
+ bugsink events stacktrace <uuid> Get formatted stacktrace
394
+
395
+ bugsink releases list --project=<id> List releases
396
+ bugsink releases create '{"project":8,"version":"v1.0"}'
397
+
398
+ Environment Variables:
399
+ BUGSINK_API_KEY API authentication token (required)
400
+ BUGSINK_HOST API host (default: https://bugs.kopernici.cz)
401
+
402
+ Configuration File:
403
+ .bugsink Project ID for current directory
404
+
405
+ For resource-specific help:
406
+ bugsink help <resource>
407
+
408
+ Examples:
409
+ # Set up configuration
410
+ export BUGSINK_API_KEY="your-token-here"
411
+ bugsink config set-project 8
412
+ bugsink config test
413
+
414
+ # List latest issues
415
+ bugsink issues list --project=8 --sort=last_seen --order=desc --json
416
+
417
+ # Get stacktrace for an event
418
+ bugsink events stacktrace <event-uuid>
419
+
420
+ Note: Issues and Events are READ-ONLY via the API. Write operations are not supported.
421
+ HELP
422
+ end
423
+
424
+ def show_resource_help(resource)
425
+ case resource
426
+ when 'config'
427
+ puts <<~HELP
428
+ bugsink config - Configuration management
429
+
430
+ Actions:
431
+ show Show current configuration
432
+ set-project <id> Set default project ID in .bugsink file
433
+ test Test API connectivity
434
+
435
+ Examples:
436
+ bugsink config show
437
+ bugsink config set-project 8
438
+ bugsink config test
439
+ HELP
440
+ when 'teams'
441
+ puts <<~HELP
442
+ bugsink teams - Team operations
443
+
444
+ Actions:
445
+ list List all teams
446
+ get <uuid> Get team details
447
+ create <json> Create new team
448
+ update <uuid> <json> Update team
449
+
450
+ JSON Format (create):
451
+ {"name":"Team Name","visibility":"hidden"}
452
+ Visibility options: joinable, discoverable, hidden
453
+
454
+ JSON Format (update):
455
+ {"name":"New Name"} // All fields optional
456
+
457
+ Examples:
458
+ bugsink teams list --json
459
+ bugsink teams get ee4f4572-0957-4346-b433-3c605acbfa2a
460
+ bugsink teams create '{"name":"My Team","visibility":"hidden"}'
461
+ bugsink teams update <uuid> '{"name":"Updated Name"}'
462
+ HELP
463
+ when 'projects'
464
+ puts <<~HELP
465
+ bugsink projects - Project operations
466
+
467
+ Actions:
468
+ list [--team=<uuid>] List projects (optionally filtered by team)
469
+ get <id> Get project details
470
+ create <json> Create new project
471
+ update <id> <json> Update project
472
+
473
+ JSON Format (create):
474
+ {
475
+ "team":"team-uuid",
476
+ "name":"Project Name",
477
+ "visibility":"team_members",
478
+ "alert_on_new_issue":true,
479
+ "alert_on_regression":true,
480
+ "alert_on_unmute":false
481
+ }
482
+ Visibility options: joinable, discoverable, team_members
483
+
484
+ JSON Format (update):
485
+ {"name":"New Name","alert_on_new_issue":false} // All fields optional
486
+
487
+ Examples:
488
+ bugsink projects list --team=<uuid> --json
489
+ bugsink projects get 8
490
+ bugsink projects create '{"team":"<uuid>","name":"Test Project"}'
491
+ bugsink projects update 8 '{"alert_on_new_issue":false}'
492
+ HELP
493
+ when 'issues'
494
+ puts <<~HELP
495
+ bugsink issues - Issue operations (READ-ONLY)
496
+
497
+ Actions:
498
+ list --project=<id> [--sort=<field>] [--order=<asc|desc>]
499
+ get <uuid>
500
+
501
+ Options:
502
+ --project=<id> Project ID (required for list)
503
+ --sort=<field> Sort by: last_seen, digest_order (default: last_seen)
504
+ --order=<order> Sort order: asc, desc (default: desc)
505
+
506
+ Examples:
507
+ bugsink issues list --project=8 --sort=last_seen --order=desc
508
+ bugsink issues get <uuid> --json
509
+
510
+ Note: Issues are READ-ONLY. No write operations available in API.
511
+ HELP
512
+ when 'events'
513
+ puts <<~HELP
514
+ bugsink events - Event operations (READ-ONLY)
515
+
516
+ Actions:
517
+ list --issue=<uuid> [--order=<asc|desc>]
518
+ get <uuid>
519
+ stacktrace <uuid>
520
+
521
+ Options:
522
+ --issue=<uuid> Issue UUID (required for list)
523
+ --order=<order> Sort order: asc, desc (default: desc)
524
+
525
+ Examples:
526
+ bugsink events list --issue=<uuid>
527
+ bugsink events get <uuid> --json
528
+ bugsink events stacktrace <uuid>
529
+
530
+ Note: Events are READ-ONLY. Created via Sentry SDK only.
531
+ HELP
532
+ when 'releases'
533
+ puts <<~HELP
534
+ bugsink releases - Release operations
535
+
536
+ Actions:
537
+ list --project=<id> List releases for project
538
+ get <uuid> Get release details
539
+ create <json> Create new release
540
+
541
+ JSON Format (create):
542
+ {
543
+ "project":8,
544
+ "version":"v1.2.3",
545
+ "timestamp":"2026-02-03T12:00:00Z" // Optional
546
+ }
547
+
548
+ Examples:
549
+ bugsink releases list --project=8
550
+ bugsink releases get <uuid>
551
+ bugsink releases create '{"project":8,"version":"v1.2.3"}'
552
+
553
+ Note: Releases can be created but not updated or deleted.
554
+ HELP
555
+ else
556
+ error("Unknown resource: #{resource}")
557
+ show_general_help
558
+ end
559
+ end
560
+
561
+ def success(message)
562
+ puts "✓ #{message}"
563
+ end
564
+
565
+ def info(message)
566
+ puts "ℹ #{message}"
567
+ end
568
+
569
+ def error(message)
570
+ warn "✗ #{message}"
571
+ end
572
+ end
573
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'json'
5
+ require_relative 'config'
6
+
7
+ module Bugsink
8
+ # HTTP client for BugSink API
9
+ class Client
10
+ include HTTParty
11
+
12
+ class ClientError < StandardError
13
+ attr_reader :code, :response
14
+
15
+ def initialize(message, code: nil, response: nil)
16
+ super(message)
17
+ @code = code
18
+ @response = response
19
+ end
20
+ end
21
+
22
+ class Response
23
+ attr_reader :data, :next_cursor
24
+
25
+ def initialize(response_body)
26
+ @data = response_body.is_a?(Hash) ? [response_body] : (response_body || [])
27
+ @next_cursor = extract_next_cursor(@data)
28
+ end
29
+
30
+ def initialize_from_list(response_body)
31
+ @data = response_body['results'] || []
32
+ @next_cursor = response_body['next']
33
+ end
34
+
35
+ def success?
36
+ true
37
+ end
38
+
39
+ def add_data(new_data)
40
+ @data.concat(new_data)
41
+ end
42
+
43
+ def has_more?
44
+ !@next_cursor.nil?
45
+ end
46
+
47
+ private
48
+
49
+ def extract_next_cursor(data)
50
+ return nil unless data.is_a?(Hash)
51
+ data['next']
52
+ end
53
+ end
54
+
55
+ attr_reader :config
56
+
57
+ def initialize(config = nil)
58
+ @config = config || Config.new
59
+ @config.validate!
60
+
61
+ self.class.base_uri @config.host
62
+ self.class.headers @config.authorization_header
63
+ self.class.headers 'Content-Type' => 'application/json'
64
+ self.class.headers 'Accept' => 'application/json'
65
+ end
66
+
67
+ # Teams
68
+ def teams_list
69
+ response = self.class.get('/api/canonical/0/teams/')
70
+ check_response(response)
71
+ parse_list_response(response)
72
+ end
73
+
74
+ def team_get(uuid)
75
+ response = self.class.get("/api/canonical/0/teams/#{uuid}/")
76
+ check_response(response)
77
+ response.parsed_response
78
+ end
79
+
80
+ def team_create(name:, visibility: nil)
81
+ body = { name: name }
82
+ body[:visibility] = visibility if visibility
83
+
84
+ response = self.class.post('/api/canonical/0/teams/', body: body.to_json)
85
+ check_response(response)
86
+ response.parsed_response
87
+ end
88
+
89
+ def team_update(uuid, name: nil, visibility: nil)
90
+ body = {}
91
+ body[:name] = name if name
92
+ body[:visibility] = visibility if visibility
93
+
94
+ raise ArgumentError, 'At least one field must be provided for update' if body.empty?
95
+
96
+ response = self.class.patch("/api/canonical/0/teams/#{uuid}/", body: body.to_json)
97
+ check_response(response)
98
+ response.parsed_response
99
+ end
100
+
101
+ # Projects
102
+ def projects_list(team_uuid: nil)
103
+ query = {}
104
+ query[:team] = team_uuid if team_uuid
105
+
106
+ response = self.class.get('/api/canonical/0/projects/', query: query)
107
+ check_response(response)
108
+ parse_list_response(response)
109
+ end
110
+
111
+ def project_get(id)
112
+ response = self.class.get("/api/canonical/0/projects/#{id}/")
113
+ check_response(response)
114
+ response.parsed_response
115
+ end
116
+
117
+ def project_create(team_uuid:, name:, visibility: nil, alert_on_new_issue: nil, alert_on_regression: nil, alert_on_unmute: nil)
118
+ body = {
119
+ team: team_uuid,
120
+ name: name
121
+ }
122
+ body[:visibility] = visibility if visibility
123
+ body[:alert_on_new_issue] = alert_on_new_issue unless alert_on_new_issue.nil?
124
+ body[:alert_on_regression] = alert_on_regression unless alert_on_regression.nil?
125
+ body[:alert_on_unmute] = alert_on_unmute unless alert_on_unmute.nil?
126
+
127
+ response = self.class.post('/api/canonical/0/projects/', body: body.to_json)
128
+ check_response(response)
129
+ response.parsed_response
130
+ end
131
+
132
+ def project_update(id, name: nil, visibility: nil, alert_on_new_issue: nil, alert_on_regression: nil, alert_on_unmute: nil)
133
+ body = {}
134
+ body[:name] = name if name
135
+ body[:visibility] = visibility if visibility
136
+ body[:alert_on_new_issue] = alert_on_new_issue unless alert_on_new_issue.nil?
137
+ body[:alert_on_regression] = alert_on_regression unless alert_on_regression.nil?
138
+ body[:alert_on_unmute] = alert_on_unmute unless alert_on_unmute.nil?
139
+
140
+ raise ArgumentError, 'At least one field must be provided for update' if body.empty?
141
+
142
+ response = self.class.patch("/api/canonical/0/projects/#{id}/", body: body.to_json)
143
+ check_response(response)
144
+ response.parsed_response
145
+ end
146
+
147
+ # Issues
148
+ def issues_list(project_id:, sort: 'last_seen', order: 'desc', limit: 250, cursor: nil)
149
+ query = {
150
+ project: project_id,
151
+ sort: sort,
152
+ order: order,
153
+ limit: limit
154
+ }
155
+ query[:cursor] = cursor if cursor
156
+
157
+ response = self.class.get('/api/canonical/0/issues/', query: query)
158
+ check_response(response)
159
+ parse_list_response(response)
160
+ end
161
+
162
+ def issue_get(uuid)
163
+ response = self.class.get("/api/canonical/0/issues/#{uuid}/")
164
+ check_response(response)
165
+ response.parsed_response
166
+ end
167
+
168
+ # Events
169
+ def events_list(issue_uuid:, order: 'desc', limit: 250, cursor: nil)
170
+ query = {
171
+ issue: issue_uuid,
172
+ order: order,
173
+ limit: limit
174
+ }
175
+ query[:cursor] = cursor if cursor
176
+
177
+ response = self.class.get('/api/canonical/0/events/', query: query)
178
+ check_response(response)
179
+ parse_list_response(response)
180
+ end
181
+
182
+ def event_get(uuid)
183
+ response = self.class.get("/api/canonical/0/events/#{uuid}/")
184
+ check_response(response)
185
+ response.parsed_response
186
+ end
187
+
188
+ def event_stacktrace(uuid)
189
+ response = self.class.get("/api/canonical/0/events/#{uuid}/stacktrace/")
190
+ check_response(response)
191
+ response.body
192
+ end
193
+
194
+ # Releases
195
+ def releases_list(project_id:)
196
+ query = { project: project_id }
197
+
198
+ response = self.class.get('/api/canonical/0/releases/', query: query)
199
+ check_response(response)
200
+ parse_list_response(response)
201
+ end
202
+
203
+ def release_get(uuid)
204
+ response = self.class.get("/api/canonical/0/releases/#{uuid}/")
205
+ check_response(response)
206
+ response.parsed_response
207
+ end
208
+
209
+ def release_create(project_id:, version:, timestamp: nil)
210
+ body = {
211
+ project: project_id,
212
+ version: version
213
+ }
214
+ body[:timestamp] = timestamp if timestamp
215
+
216
+ response = self.class.post('/api/canonical/0/releases/', body: body.to_json)
217
+ check_response(response)
218
+ response.parsed_response
219
+ end
220
+
221
+ # Test connectivity
222
+ def test_connection
223
+ response = self.class.get('/api/canonical/0/teams/')
224
+ check_response(response)
225
+ true
226
+ rescue ClientError => e
227
+ raise ClientError.new("Connection test failed: #{e.message}", code: e.code, response: e.response)
228
+ end
229
+
230
+ private
231
+
232
+ def check_response(response)
233
+ return response if response.code >= 200 && response.code < 300
234
+
235
+ error_message = "HTTP #{response.code}"
236
+ begin
237
+ error_body = response.parsed_response
238
+ error_message += ": #{error_body}" if error_body
239
+ rescue JSON::ParserError
240
+ error_message += ": #{response.body}"
241
+ end
242
+
243
+ raise ClientError.new(error_message, code: response.code, response: response.body)
244
+ end
245
+
246
+ def parse_list_response(response)
247
+ body = response.parsed_response
248
+
249
+ # Handle paginated list responses
250
+ if body.is_a?(Hash) && body.key?('results')
251
+ resp = Response.new(nil)
252
+ resp.initialize_from_list(body)
253
+ resp
254
+ else
255
+ # Handle simple array responses (like teams)
256
+ Response.new(body)
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bugsink
4
+ # Configuration management for BugSink API client
5
+ class Config
6
+ class ConfigError < StandardError; end
7
+
8
+ attr_reader :api_key, :host, :project_id
9
+
10
+ DOTFILE = '.bugsink'.freeze
11
+
12
+ def initialize
13
+ @api_key = ENV['BUGSINK_API_KEY']
14
+ @host = ENV.fetch('BUGSINK_HOST', 'https://bugs.kopernici.cz')
15
+ @project_id = read_project_id
16
+ end
17
+
18
+ def valid?
19
+ !api_key.nil? && !api_key.empty?
20
+ end
21
+
22
+ def validate!
23
+ raise ConfigError, 'BUGSINK_API_KEY environment variable is required' unless valid?
24
+ end
25
+
26
+ def authorization_header
27
+ { 'Authorization' => "Bearer #{api_key}" }
28
+ end
29
+
30
+ def set_project_id(id)
31
+ File.write(dotfile_path, "PROJECT_ID=#{id}\n")
32
+ @project_id = id
33
+ end
34
+
35
+ def project_id_present?
36
+ !project_id.nil?
37
+ end
38
+
39
+ def to_s
40
+ <<~CONFIG
41
+ BugSink Configuration:
42
+ Host: #{host}
43
+ API Key: #{api_key ? "#{api_key[0..8]}...#{api_key[-8..]}" : 'not set'}
44
+ Project ID: #{project_id || 'not set'}
45
+ CONFIG
46
+ end
47
+
48
+ private
49
+
50
+ def read_project_id
51
+ return nil unless File.exist?(dotfile_path)
52
+
53
+ content = File.read(dotfile_path).strip
54
+ match = content.match(/^PROJECT_ID=(\d+)$/)
55
+ match ? match[1].to_i : nil
56
+ end
57
+
58
+ def dotfile_path
59
+ File.join(Dir.pwd, DOTFILE)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bugsink
4
+ VERSION = '0.1.0'
5
+ end
data/lib/bugsink.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bugsink/version'
4
+ require_relative 'bugsink/config'
5
+ require_relative 'bugsink/client'
6
+ require_relative 'bugsink/cli'
7
+
8
+ module Bugsink
9
+ class Error < StandardError; end
10
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bugsink-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tomas Kopernik
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: httparty
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.22'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.22'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.13'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.13'
54
+ description: A command-line interface for the BugSink error tracking service, providing
55
+ full API access for teams, projects, issues, events, and releases.
56
+ email:
57
+ - tomas@kopernik.cz
58
+ executables:
59
+ - bugsink
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE
65
+ - README.md
66
+ - exe/bugsink
67
+ - lib/bugsink.rb
68
+ - lib/bugsink/cli.rb
69
+ - lib/bugsink/client.rb
70
+ - lib/bugsink/config.rb
71
+ - lib/bugsink/version.rb
72
+ homepage: https://github.com/koperniki/bugsink-cli
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://github.com/koperniki/bugsink-cli
77
+ source_code_uri: https://github.com/koperniki/bugsink-cli
78
+ changelog_uri: https://github.com/koperniki/bugsink-cli/blob/main/CHANGELOG.md
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.3.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.6.9
94
+ specification_version: 4
95
+ summary: CLI tool for interacting with BugSink error tracking API
96
+ test_files: []