eussiror 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: e721e700a3b37ec73117c35b65774e66c1f9274d249b12b16ea331c62bf34ef9
4
+ data.tar.gz: 20ddffdf0b98103b3777544046a271100383bf4720a7cf0d6a66e9075c2ecf1d
5
+ SHA512:
6
+ metadata.gz: 72534f05812764111a22210f39521833ba30651347e1e68cd335f673c2e832258489f742c417191643a1a1e3cb29cd0d0ceb71d1695679c07ca6ce97e84627b1
7
+ data.tar.gz: 59b04321d5af06bbcd215290b26f2665ac6b3751bec5d76208041c28358e5a456da6c9be475cbe4bb6b696606fe0062b944561d9d74a277ee88de2bbac261ece
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
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.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-02-26
11
+
12
+ ### Added
13
+ - Initial release.
14
+ - Rack middleware that detects 500 responses and reads `env["action_dispatch.exception"]`.
15
+ - SHA256-based fingerprinting to deduplicate errors across occurrences.
16
+ - GitHub REST API v3 client (zero runtime dependencies, uses `Net::HTTP`).
17
+ - Automatic issue creation on first occurrence of a given error.
18
+ - Automatic comment on existing open issue for repeat occurrences.
19
+ - `Eussiror.configure` block with support for token, repository, environments, labels, assignees, ignored exceptions, and async mode.
20
+ - Rails install generator (`rails generate eussiror:install`).
21
+ - RuboCop configuration.
22
+ - GitHub Actions CI matrix: Ruby 3.1–3.4 × Rails 7.2–8.1.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Equipe Technique
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,314 @@
1
+ # Eussiror
2
+
3
+ **Maintainer:** [@tracyloisel](https://github.com/tracyloisel)
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/eussiror.svg)](https://badge.fury.io/rb/eussiror)
6
+ [![CI](https://github.com/tracyloisel/eussiror/actions/workflows/ci.yml/badge.svg)](https://github.com/tracyloisel/eussiror/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+
9
+ **Eussiror** automatically creates GitHub issues when your Rails application returns a 500 error in production. If the same error already has an open issue, it adds a comment with the new occurrence timestamp instead — keeping your issue tracker clean and deduplicated.
10
+
11
+ ---
12
+
13
+ ## Table of Contents
14
+
15
+ - [Requirements](#requirements)
16
+ - [Installation](#installation)
17
+ - [Configuration](#configuration)
18
+ - [How it works](#how-it-works)
19
+ - [GitHub token setup](#github-token-setup)
20
+ - [Architecture (for contributors)](#architecture-for-contributors)
21
+ - [Development](#development)
22
+ - [Contributing](#contributing)
23
+ - [License](#license)
24
+
25
+ ---
26
+
27
+ ## Requirements
28
+
29
+ | Dependency | Minimum version |
30
+ |---|---|
31
+ | Ruby | 3.1 |
32
+ | Rails | 7.2 |
33
+
34
+ > **Note:** No additional runtime gems are required. Eussiror uses Ruby's built-in `Net::HTTP` to call the GitHub API.
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ Add the gem to your application's `Gemfile`:
41
+
42
+ ```ruby
43
+ gem "eussiror"
44
+ ```
45
+
46
+ Then run:
47
+
48
+ ```bash
49
+ bundle install
50
+ rails generate eussiror:install
51
+ ```
52
+
53
+ The generator creates `config/initializers/eussiror.rb` with all available options commented out. To undo the installation:
54
+
55
+ ```bash
56
+ rails destroy eussiror:install
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Configuration
62
+
63
+ Edit the generated initializer:
64
+
65
+ ```ruby
66
+ # config/initializers/eussiror.rb
67
+ Eussiror.configure do |config|
68
+ # Required: GitHub personal access token with "repo" scope
69
+ config.github_token = ENV["GITHUB_TOKEN"]
70
+
71
+ # Required: target repository in "owner/repository" format
72
+ config.github_repository = "your-org/your-repo"
73
+
74
+ # Environments where 500 errors will be reported (default: ["production"])
75
+ config.environments = %w[production]
76
+
77
+ # Labels applied to every new issue (optional)
78
+ config.labels = %w[bug automated]
79
+
80
+ # GitHub logins to assign to new issues (optional)
81
+ config.assignees = []
82
+
83
+ # Exception classes that should NOT trigger issue creation (optional)
84
+ config.ignored_exceptions = %w[ActionController::RoutingError]
85
+
86
+ # Set to false to report synchronously — recommended in test environments
87
+ config.async = false
88
+ end
89
+ ```
90
+
91
+ ### Configuration options
92
+
93
+ | Option | Type | Default | Description |
94
+ |---|---|---|---|
95
+ | `github_token` | String | `nil` | GitHub token with `repo` (or Issues write) permission |
96
+ | `github_repository` | String | `nil` | Target repo in `owner/repo` format |
97
+ | `environments` | Array | `["production"]` | Environments where reporting is active |
98
+ | `labels` | Array | `[]` | Labels applied to created issues |
99
+ | `assignees` | Array | `[]` | GitHub logins assigned to created issues |
100
+ | `ignored_exceptions` | Array | `[]` | Exception class names (strings) to skip |
101
+ | `async` | Boolean | `true` | Report in a background thread (set `false` in tests) |
102
+
103
+ ---
104
+
105
+ ## How it works
106
+
107
+ When a 500 error occurs:
108
+
109
+ 1. The Rack middleware catches the rendered 500 response.
110
+ 2. A **fingerprint** is computed from the exception class, message, and first application backtrace line.
111
+ 3. The GitHub API is searched for an open issue containing that fingerprint.
112
+ 4. If **no issue exists** → a new issue is created with the exception details.
113
+ 5. If **an issue exists** → a comment with the current timestamp is added.
114
+
115
+ ### Example GitHub issue
116
+
117
+ **Title:** `[500] RuntimeError: something went wrong`
118
+
119
+ **Body:**
120
+ ```
121
+ ## Error Details
122
+
123
+ **Exception:** `RuntimeError`
124
+ **Message:** something went wrong
125
+ **First occurrence:** 2026-02-26 10:30:00 UTC
126
+ **Request:** `GET /dashboard`
127
+ **Remote IP:** 1.2.3.4
128
+
129
+ ## Backtrace
130
+
131
+ app/controllers/dashboard_controller.rb:42:in 'index'
132
+ ...
133
+ ```
134
+
135
+ ### Example occurrence comment
136
+
137
+ ```
138
+ **New occurrence:** 2026-02-26 14:55:02 UTC
139
+ ```
140
+
141
+ ---
142
+
143
+ ## GitHub token setup
144
+
145
+ Eussiror needs a GitHub token with permission to read and create issues on your target repository.
146
+
147
+ **Option A — Classic personal access token:**
148
+ Generate at `Settings → Developer settings → Personal access tokens → Tokens (classic)` and select the `repo` scope.
149
+
150
+ **Option B — Fine-grained personal access token:**
151
+ Generate at `Settings → Developer settings → Personal access tokens → Fine-grained tokens`. Grant **Issues → Read and write** on the target repository only.
152
+
153
+ Store the token in an environment variable (never hard-code it):
154
+
155
+ ```bash
156
+ # .env or your secrets manager
157
+ GITHUB_TOKEN=ghp_xxxxxxxxxxxx
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Architecture (for contributors)
163
+
164
+ This section describes the internal design of Eussiror to help contributors understand where everything lives and how the pieces connect.
165
+
166
+ ### File map
167
+
168
+ ```
169
+ lib/
170
+ ├── eussiror.rb # Public API: .configure / .configuration / .reset_configuration!
171
+ └── eussiror/
172
+ ├── version.rb # Gem version constant
173
+ ├── configuration.rb # Configuration value object + guards
174
+ ├── railtie.rb # Rails integration: inserts Middleware into the stack
175
+ ├── middleware.rb # Rack middleware: detects 500s and calls ErrorReporter
176
+ ├── fingerprint.rb # Computes a stable SHA256 fingerprint per exception type
177
+ ├── github_client.rb # GitHub REST API v3 calls via Net::HTTP
178
+ └── error_reporter.rb # Orchestrator: fingerprint → search → create or comment
179
+
180
+ lib/generators/eussiror/install/
181
+ ├── install_generator.rb # `rails generate eussiror:install`
182
+ └── templates/initializer.rb.tt # Template for config/initializers/eussiror.rb
183
+ ```
184
+
185
+ ### Request / error flow
186
+
187
+ ```
188
+ HTTP Request
189
+
190
+
191
+ Eussiror::Middleware (outermost Rack middleware)
192
+
193
+
194
+ ActionDispatch::ShowExceptions (catches Rails exceptions, stores them in env)
195
+
196
+
197
+ [... rest of Rails stack ...]
198
+
199
+ ▼ (response travels back up)
200
+ ActionDispatch::ShowExceptions → sets env["action_dispatch.exception"]
201
+ returns HTTP 500 response
202
+
203
+
204
+ Eussiror::Middleware
205
+ ├── status == 500 AND env["action_dispatch.exception"] present?
206
+ │ YES → ErrorReporter.report(exception, env)
207
+ │ NO → pass response through unchanged
208
+
209
+
210
+ HTTP Response returned to client
211
+ ```
212
+
213
+ ### Component responsibilities
214
+
215
+ #### `Eussiror` (lib/eussiror.rb)
216
+ Top-level module. Holds the singleton `configuration` object and exposes `.configure { |c| }`. All other components read `Eussiror.configuration`.
217
+
218
+ #### `Eussiror::Configuration`
219
+ Plain Ruby value object with attr_accessors for every option. Contains the two guard predicates used by `ErrorReporter`:
220
+ - `#valid?` — both token and repository are present
221
+ - `#reporting_enabled?` — valid config AND current Rails env is in `environments`
222
+
223
+ #### `Eussiror::Railtie`
224
+ Rails `Railtie` that runs one initializer: it inserts `Eussiror::Middleware` **before** `ActionDispatch::ShowExceptions` in the middleware stack. This positions our middleware as the outermost wrapper, so it sees the fully rendered 500 response on the way back out.
225
+
226
+ #### `Eussiror::Middleware`
227
+ Rack middleware with a standard `#call(env)` interface.
228
+ - On a normal response: passes through.
229
+ - On a 500 response with `env["action_dispatch.exception"]`: calls `ErrorReporter.report`.
230
+ - On a re-raised exception (non-standard setups): calls `ErrorReporter.report` before re-raising.
231
+
232
+ #### `Eussiror::Fingerprint`
233
+ Stateless module with a single public method: `.compute(exception) → String`.
234
+
235
+ The fingerprint is a 12-character hex prefix of a SHA256 digest computed from:
236
+ ```
237
+ "#{exception.class.name}|#{exception.message[0,200]}|#{first_app_backtrace_line}"
238
+ ```
239
+ Gem and stdlib lines are excluded when looking for the "first app line". This makes the fingerprint stable across deployments while being unique per error location.
240
+
241
+ The fingerprint is embedded as an HTML comment in the issue body:
242
+ ```
243
+ <!-- eussiror:fingerprint:a1b2c3d4e5f6 -->
244
+ ```
245
+
246
+ #### `Eussiror::GithubClient`
247
+ Thin HTTP client wrapping three GitHub REST API v3 endpoints. Uses only `Net::HTTP` (stdlib). Requires a `token:` and `repository:` at construction time.
248
+
249
+ | Method | Endpoint | Purpose |
250
+ |---|---|---|
251
+ | `#find_issue(fingerprint)` | `GET /search/issues` | Returns issue number or `nil` |
252
+ | `#create_issue(title:, body:, ...)` | `POST /repos/{owner}/{repo}/issues` | Returns new issue number |
253
+ | `#add_comment(issue_number, body:)` | `POST /repos/{owner}/{repo}/issues/{n}/comments` | Returns comment id |
254
+
255
+ #### `Eussiror::ErrorReporter`
256
+ Stateless module that orchestrates the full reporting flow. Called by the middleware.
257
+
258
+ 1. Checks `Eussiror.configuration.reporting_enabled?` — returns early if not.
259
+ 2. Checks `ignored_exceptions` — returns early if matched.
260
+ 3. Dispatches in a `Thread.new` when `config.async` is `true` (default), or inline otherwise.
261
+ 4. Computes fingerprint → searches GitHub → creates issue or adds comment.
262
+ 5. All GitHub errors are rescued and emitted as `warn` messages — the gem **never crashes your app**.
263
+
264
+ #### `Eussiror::Generators::InstallGenerator`
265
+ Standard `Rails::Generators::Base` subclass. Copies `templates/initializer.rb.tt` to `config/initializers/eussiror.rb` using Thor's `template` method. Supports `rails destroy eussiror:install` for clean uninstallation.
266
+
267
+ ### Testing approach
268
+
269
+ - **Unit specs**: each component is tested in isolation. `GithubClient` uses `WebMock` to stub HTTP calls. `ErrorReporter` uses RSpec doubles for `GithubClient`.
270
+ - **Generator spec**: uses Rails generator test helpers (`prepare_destination`, `run_generator`).
271
+ - **Appraisals**: the `Appraisals` file defines three gemfiles (`rails-7.2`, `rails-8.0`, `rails-8.1`) so the full test suite runs against each supported Rails version.
272
+
273
+ ---
274
+
275
+ ## Development
276
+
277
+ ```bash
278
+ # Clone and install
279
+ git clone https://github.com/tracyloisel/eussiror.git
280
+ cd eussiror
281
+ bundle install
282
+
283
+ # Run tests against all Rails versions
284
+ bundle exec appraisal install
285
+ bundle exec appraisal rspec
286
+
287
+ # Run tests against a specific Rails version
288
+ bundle exec appraisal rails-8.0 rspec
289
+
290
+ # Run the linter
291
+ bundle exec rubocop
292
+
293
+ # Run the linter with auto-correct
294
+ bundle exec rubocop -A
295
+ ```
296
+
297
+ ---
298
+
299
+ ## Contributing
300
+
301
+ 1. Fork the repository
302
+ 2. Create a feature branch: `git checkout -b feature/my-feature`
303
+ 3. Write tests for your change
304
+ 4. Make the tests pass: `bundle exec appraisal rspec`
305
+ 5. Make the linter pass: `bundle exec rubocop`
306
+ 6. Open a pull request against `main`
307
+
308
+ Please follow the existing code style. All public behaviour must be covered by specs.
309
+
310
+ ---
311
+
312
+ ## License
313
+
314
+ The gem is available as open source under the [MIT License](LICENSE).
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eussiror
4
+ class Configuration
5
+ # Required settings
6
+ attr_accessor :github_token, :github_repository
7
+
8
+ # Environments where issue reporting is active (default: production only)
9
+ attr_accessor :environments
10
+
11
+ # Optional GitHub issue metadata
12
+ attr_accessor :labels, :assignees
13
+
14
+ # Exception classes to ignore (array of strings)
15
+ attr_accessor :ignored_exceptions
16
+
17
+ # Set to false to report synchronously (useful in tests)
18
+ attr_accessor :async
19
+
20
+ def initialize
21
+ @environments = %w[production]
22
+ @labels = []
23
+ @assignees = []
24
+ @ignored_exceptions = []
25
+ @async = true
26
+ end
27
+
28
+ def valid?
29
+ github_token.to_s.strip.length.positive? &&
30
+ github_repository.to_s.strip.length.positive?
31
+ end
32
+
33
+ def reporting_enabled?
34
+ valid? && environments.include?(current_environment)
35
+ end
36
+
37
+ private
38
+
39
+ def current_environment
40
+ defined?(Rails) ? Rails.env.to_s : ENV.fetch("RAILS_ENV", "development")
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eussiror
4
+ module ErrorReporter
5
+ # Maximum number of backtrace lines included in an issue body.
6
+ MAX_BACKTRACE_LINES = 20
7
+
8
+ class << self
9
+ # Entry point called by the middleware.
10
+ # Checks configuration guards, then dispatches async or sync.
11
+ def report(exception, env = {})
12
+ config = Eussiror.configuration
13
+
14
+ return unless config.reporting_enabled?
15
+ return if ignored?(exception, config)
16
+
17
+ if config.async
18
+ Thread.new { process(exception, env, config) }
19
+ else
20
+ process(exception, env, config)
21
+ end
22
+ rescue StandardError => e
23
+ warn "[Eussiror] ErrorReporter.report raised an unexpected error: #{e.class}: #{e.message}"
24
+ end
25
+
26
+ private
27
+
28
+ def ignored?(exception, config)
29
+ config.ignored_exceptions.any? do |klass_name|
30
+ exception.is_a?(Object.const_get(klass_name))
31
+ rescue NameError
32
+ false
33
+ end
34
+ end
35
+
36
+ def process(exception, env, config)
37
+ fingerprint = Fingerprint.compute(exception)
38
+ client = GithubClient.new(
39
+ token: config.github_token,
40
+ repository: config.github_repository
41
+ )
42
+
43
+ existing_issue = client.find_issue(fingerprint)
44
+
45
+ if existing_issue
46
+ client.add_comment(existing_issue, body: occurrence_comment)
47
+ else
48
+ client.create_issue(
49
+ title: issue_title(exception),
50
+ body: issue_body(exception, env, fingerprint),
51
+ labels: config.labels,
52
+ assignees: config.assignees
53
+ )
54
+ end
55
+ rescue StandardError => e
56
+ warn "[Eussiror] Failed to report exception to GitHub: #{e.class}: #{e.message}"
57
+ end
58
+
59
+ def issue_title(exception)
60
+ message = exception.message.to_s.lines.first.to_s.strip[0, 120]
61
+ "[500] #{exception.class}: #{message}"
62
+ end
63
+
64
+ def issue_body(exception, env, fingerprint)
65
+ request_info = build_request_info(env)
66
+ backtrace = format_backtrace(exception)
67
+
68
+ <<~BODY
69
+ ## Error Details
70
+
71
+ **Exception:** `#{exception.class}`
72
+ **Message:** #{exception.message}
73
+ **First occurrence:** #{current_timestamp}
74
+ #{request_info}
75
+
76
+ ## Backtrace
77
+
78
+ ```
79
+ #{backtrace}
80
+ ```
81
+
82
+ <!-- #{GithubClient::FINGERPRINT_MARKER}:#{fingerprint} -->
83
+ BODY
84
+ end
85
+
86
+ def occurrence_comment
87
+ "**New occurrence:** #{current_timestamp}"
88
+ end
89
+
90
+ def current_timestamp
91
+ Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
92
+ end
93
+
94
+ def build_request_info(env)
95
+ return "" if env.nil? || env.empty?
96
+
97
+ method = env["REQUEST_METHOD"]
98
+ path = env["PATH_INFO"]
99
+ remote_addr = env["REMOTE_ADDR"]
100
+
101
+ return "" unless method && path
102
+
103
+ parts = ["**Request:** `#{method} #{path}`"]
104
+ parts << "**Remote IP:** #{remote_addr}" if remote_addr
105
+
106
+ "\n#{parts.join("\n")}"
107
+ end
108
+
109
+ def format_backtrace(exception)
110
+ (exception.backtrace || [])
111
+ .first(MAX_BACKTRACE_LINES)
112
+ .join("\n")
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Eussiror
6
+ module Fingerprint
7
+ # Number of hex characters kept from the SHA256 digest.
8
+ DIGEST_LENGTH = 12
9
+
10
+ # Lines from these path fragments are excluded when looking for the
11
+ # "first application line" in a backtrace.
12
+ GEM_PATH_PATTERNS = %w[/gems/ /ruby/ /rubygems vendor/bundle].freeze
13
+
14
+ # Computes a stable, short fingerprint for a given exception.
15
+ #
16
+ # The fingerprint is based on:
17
+ # - The exception class name
18
+ # - The first 200 characters of the message
19
+ # - The first backtrace line that belongs to the application (not a gem)
20
+ #
21
+ # Returns a 12-character lowercase hex string.
22
+ def self.compute(exception)
23
+ parts = [
24
+ exception.class.name,
25
+ exception.message.to_s[0, 200],
26
+ first_app_backtrace_line(exception)
27
+ ]
28
+
29
+ Digest::SHA256.hexdigest(parts.join("|"))[0, DIGEST_LENGTH]
30
+ end
31
+
32
+ def self.first_app_backtrace_line(exception)
33
+ backtrace = exception.backtrace || []
34
+ backtrace.find { |line| GEM_PATH_PATTERNS.none? { |pattern| line.include?(pattern) } } || backtrace.first.to_s
35
+ end
36
+ private_class_method :first_app_backtrace_line
37
+ end
38
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Eussiror
8
+ class GithubClient
9
+ GITHUB_API_BASE = "https://api.github.com"
10
+ # Marker embedded as an HTML comment in every issue body for searching.
11
+ FINGERPRINT_MARKER = "eussiror:fingerprint"
12
+
13
+ def initialize(token:, repository:)
14
+ @token = token
15
+ @repository = repository
16
+ end
17
+
18
+ # Searches for an open issue whose body contains the given fingerprint.
19
+ # Returns the issue number (Integer) or nil when none is found.
20
+ def find_issue(fingerprint)
21
+ query = "repo:#{@repository} is:issue is:open \"#{FINGERPRINT_MARKER}:#{fingerprint}\" in:body"
22
+ params = URI.encode_www_form(q: query, per_page: 1)
23
+ uri = URI("#{GITHUB_API_BASE}/search/issues?#{params}")
24
+
25
+ response = get(uri)
26
+ data = JSON.parse(response.body)
27
+
28
+ return nil unless response.is_a?(Net::HTTPSuccess)
29
+ return nil if data["items"].nil? || data["items"].empty?
30
+
31
+ data["items"].first["number"]
32
+ end
33
+
34
+ # Creates a new GitHub issue and returns the issue number.
35
+ def create_issue(title:, body:, labels: [], assignees: [])
36
+ uri = URI("#{GITHUB_API_BASE}/repos/#{@repository}/issues")
37
+ payload = { title: title, body: body }
38
+ payload[:labels] = labels if labels.any?
39
+ payload[:assignees] = assignees if assignees.any?
40
+
41
+ response = post(uri, payload)
42
+ data = JSON.parse(response.body)
43
+
44
+ raise_api_error!(response, "create issue") unless response.is_a?(Net::HTTPSuccess)
45
+
46
+ data["number"]
47
+ end
48
+
49
+ # Adds a comment to an existing issue. Returns the comment id.
50
+ def add_comment(issue_number, body:)
51
+ uri = URI("#{GITHUB_API_BASE}/repos/#{@repository}/issues/#{issue_number}/comments")
52
+
53
+ response = post(uri, { body: body })
54
+ data = JSON.parse(response.body)
55
+
56
+ raise_api_error!(response, "add comment") unless response.is_a?(Net::HTTPSuccess)
57
+
58
+ data["id"]
59
+ end
60
+
61
+ private
62
+
63
+ def get(uri)
64
+ request = Net::HTTP::Get.new(uri)
65
+ apply_headers!(request)
66
+ execute(uri, request)
67
+ end
68
+
69
+ def post(uri, payload)
70
+ request = Net::HTTP::Post.new(uri)
71
+ apply_headers!(request)
72
+ request.body = JSON.generate(payload)
73
+ execute(uri, request)
74
+ end
75
+
76
+ def execute(uri, request)
77
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
78
+ http.request(request)
79
+ end
80
+ end
81
+
82
+ def apply_headers!(request)
83
+ request["Authorization"] = "Bearer #{@token}"
84
+ request["Accept"] = "application/vnd.github+json"
85
+ request["X-GitHub-Api-Version"] = "2022-11-28"
86
+ request["Content-Type"] = "application/json"
87
+ request["User-Agent"] = "eussiror/#{Eussiror::VERSION}"
88
+ end
89
+
90
+ def raise_api_error!(response, action)
91
+ raise "Eussiror: GitHub API failed to #{action} " \
92
+ "(HTTP #{response.code}): #{response.body}"
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eussiror
4
+ class Middleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ status, headers, body = @app.call(env)
11
+
12
+ if status == 500
13
+ exception = env["action_dispatch.exception"]
14
+ ErrorReporter.report(exception, env) if exception
15
+ end
16
+
17
+ [status, headers, body]
18
+ rescue Exception => e # rubocop:disable Lint/RescueException
19
+ # The Rails stack re-raises after ShowExceptions in non-standard setups.
20
+ # We still want to capture the exception before propagating it.
21
+ ErrorReporter.report(e, env)
22
+ raise
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Eussiror
6
+ class Railtie < Rails::Railtie
7
+ # Insert before ShowExceptions so we wrap the full Rails error rendering.
8
+ # On the way back out, we inspect the rendered response and env to detect 500s.
9
+ initializer "eussiror.insert_middleware" do |app|
10
+ app.middleware.insert_before ActionDispatch::ShowExceptions, Eussiror::Middleware
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eussiror
4
+ VERSION = "0.1.0"
5
+ end
data/lib/eussiror.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "eussiror/version"
4
+ require "eussiror/configuration"
5
+ require "eussiror/fingerprint"
6
+ require "eussiror/github_client"
7
+ require "eussiror/error_reporter"
8
+ require "eussiror/middleware"
9
+ require "eussiror/railtie" if defined?(Rails::Railtie)
10
+
11
+ module Eussiror
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield configuration
19
+ end
20
+
21
+ def reset_configuration!
22
+ @configuration = Configuration.new
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Eussiror
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates an Eussiror initializer in config/initializers."
11
+
12
+ def create_initializer_file
13
+ template "initializer.rb.tt", "config/initializers/eussiror.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ Eussiror.configure do |config|
2
+ # Required: GitHub personal access token with "repo" scope (or a fine-grained
3
+ # token with Issues read/write permission on the target repository).
4
+ config.github_token = ENV["GITHUB_TOKEN"]
5
+
6
+ # Required: target GitHub repository in "owner/repository" format.
7
+ config.github_repository = "your-org/your-repo"
8
+
9
+ # Environments where 500 errors will be reported to GitHub.
10
+ # Default: ["production"]
11
+ config.environments = %w[production]
12
+
13
+ # Labels applied to every new issue created by Eussiror (optional).
14
+ # config.labels = %w[bug automated]
15
+
16
+ # GitHub login(s) to assign to new issues (optional).
17
+ # config.assignees = []
18
+
19
+ # Exception classes that should NOT trigger issue creation (optional).
20
+ # config.ignored_exceptions = %w[ActionController::RoutingError]
21
+
22
+ # Set to false to report synchronously instead of in a background thread.
23
+ # Recommended for test environments or when using a job queue.
24
+ # config.async = false
25
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: eussiror
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Equipe Technique
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.2'
27
+ description: |
28
+ Eussiror hooks into your Rails app and automatically creates GitHub issues
29
+ when unhandled exceptions produce 500 responses in configured environments.
30
+ If an issue already exists for the same error (identified by fingerprint),
31
+ it adds a comment with the new occurrence timestamp instead.
32
+ email: []
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE
39
+ - README.md
40
+ - lib/eussiror.rb
41
+ - lib/eussiror/configuration.rb
42
+ - lib/eussiror/error_reporter.rb
43
+ - lib/eussiror/fingerprint.rb
44
+ - lib/eussiror/github_client.rb
45
+ - lib/eussiror/middleware.rb
46
+ - lib/eussiror/railtie.rb
47
+ - lib/eussiror/version.rb
48
+ - lib/generators/eussiror/install/install_generator.rb
49
+ - lib/generators/eussiror/install/templates/initializer.rb.tt
50
+ homepage: https://github.com/tracyloisel/eussiror
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://github.com/tracyloisel/eussiror
55
+ source_code_uri: https://github.com/tracyloisel/eussiror
56
+ changelog_uri: https://github.com/tracyloisel/eussiror/blob/main/CHANGELOG.md
57
+ rubygems_mfa_required: 'true'
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 3.1.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.5.22
74
+ signing_key:
75
+ specification_version: 4
76
+ summary: Automatically create GitHub issues from Rails 500 errors
77
+ test_files: []