snitch-rails 0.2.4 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24e7f769e5a0ca5e3af5c85f174944fcb9883f6f70cef92f42dc29eedca8bcf5
4
- data.tar.gz: e286ffc4b8320d31f5e1345005feb186991386a95197df8db9ae3dd7ecab62e2
3
+ metadata.gz: a33dc497c1b82d6d45ebc526e83f27eb51cb683fcf7eb3634d0b94aa020be386
4
+ data.tar.gz: 1302300c77ed110f702cb2f6b5817ce71e87ad1cfe35845a1935776a2f6d830c
5
5
  SHA512:
6
- metadata.gz: 64eda4f8fd955360c4f0dbf03cfc00ad4fff5ddb0bc99903bb2982d59b36a4cf48218fa934209d813d85d15c4cdb4fc7107ab58c598b9d6139de9b6de70f2678
7
- data.tar.gz: b9e9e351fb45771c3863fc77127b53db784bd87ad49e8d599fab393bffd3b417a59886629ac4377336e5dc62cd89c82b05932ce8b671b9525ba516752a9d4741
6
+ metadata.gz: d7bbd6b45dc3f1ed501940526360912793e60b7b18eae32c10b32b48f5e90ebb0497cb450e8581fa99e6d14144eabce3c5a03d61069ab37fc3968c5024e573a2
7
+ data.tar.gz: e7996ecc883c3a112211c31a174a3281f1a03898bb8af2c994aef9df21648619dac8e442099becc6223398dcae402fd1e69a07c83552b506cb61562c106f5b7a
data/README.md CHANGED
@@ -4,6 +4,15 @@ Snitch catches unhandled exceptions in your Rails application, persists them to
4
4
 
5
5
  ![Example GitHub issue created by Snitch](example.png)
6
6
 
7
+ ## Features
8
+
9
+ - Automatic exception capture via Rack middleware
10
+ - Fingerprinting and deduplication (increments occurrence count for repeat exceptions)
11
+ - GitHub issue creation with full backtrace, request context, and @mentions
12
+ - **Dashboard** at `/snitches` with tabbed views: Open, Closed, and Ignored
13
+ - Ignore exceptions via config or directly from the dashboard
14
+ - Manual exception reporting for rescued exceptions
15
+
7
16
  ## Installation
8
17
 
9
18
  Add Snitch to your Gemfile:
@@ -18,18 +27,34 @@ Run bundle install:
18
27
  bundle install
19
28
  ```
20
29
 
21
- Run the install generator to create the migration and initializer:
30
+ Run the install generator:
22
31
 
23
32
  ```bash
24
33
  rails generate snitch:install
34
+ rails db:migrate
25
35
  ```
26
36
 
27
- Run the migration:
37
+ The installer mounts the dashboard to `config/routes.rb`:
38
+
39
+ ```ruby
40
+ mount Snitch::Engine, at: "/snitches"
41
+ ```
42
+
43
+ ### Upgrading from a previous version
44
+
45
+ If you're upgrading from a version prior to 0.3.0, run the update generator to add the `status` column to the `snitch_errors` table:
28
46
 
29
47
  ```bash
48
+ rails generate snitch:update
30
49
  rails db:migrate
31
50
  ```
32
51
 
52
+ Then mount the engine in your `config/routes.rb`:
53
+
54
+ ```ruby
55
+ mount Snitch::Engine, at: "/snitches"
56
+ ```
57
+
33
58
  ## Configuration
34
59
 
35
60
  The generator creates an initializer at `config/initializers/snitch.rb`. Update it with your settings:
@@ -50,12 +75,40 @@ Snitch.configure do |config|
50
75
 
51
76
  # Exceptions to ignore (default: ActiveRecord::RecordNotFound, ActionController::RoutingError)
52
77
  config.ignored_exceptions += [YourCustomError]
78
+
79
+ # GitHub webhook secret for auto-closing events when issues are closed (optional)
80
+ config.github_webhook_secret = ENV["SNITCH_GITHUB_WEBHOOK_SECRET"]
53
81
  end
54
82
  ```
55
83
 
56
84
  ### GitHub Token
57
85
 
58
- Create a [personal access token](https://github.com/settings/tokens) with the `repo` scope and set it as an environment variable:
86
+ Create a [personal access token](https://github.com/settings/tokens) with the `repo` scope and set it as an environment variable.
87
+
88
+ ## Dashboard
89
+
90
+ Visit `/snitches` in your browser to view the Snitch dashboard.
91
+
92
+ ![Snitch Dashboard](dashboard.png)
93
+
94
+ The dashboard provides three tabs:
95
+
96
+ - **Open** — Exceptions that need attention. Shows the most recently occurred first.
97
+ - **Closed** — Exceptions that have been resolved. You can reopen them if they recur.
98
+ - **Ignored** — Exceptions you've chosen to suppress. Ignored exceptions will not create new GitHub issues even if they occur again.
99
+
100
+ From the dashboard you can change the status of any exception:
101
+ - Open exceptions can be **closed** or **ignored**
102
+ - Closed or ignored exceptions can be **reopened**
103
+
104
+ ### Ignoring Exceptions
105
+
106
+ There are two ways to ignore exceptions, and Snitch honors both:
107
+
108
+ 1. **Via the initializer** — Add exception classes to `config.ignored_exceptions`. These are filtered at the middleware level and never captured or persisted.
109
+ 2. **Via the dashboard** — Mark individual exceptions as "Ignored" from the Open tab. Future occurrences of that exception class will be silently skipped — no new records, no GitHub issues.
110
+
111
+ Both sources are checked every time an exception is caught. If an exception class appears in either the initializer config or has a record with status "ignored" in the database, it will be ignored.
59
112
 
60
113
  ## Manual Reporting
61
114
 
@@ -74,6 +127,42 @@ This is useful for exceptions you want to recover from gracefully but still want
74
127
 
75
128
  The same fingerprinting and deduplication rules apply. If the same exception is reported multiple times, Snitch will increment the occurrence count and comment on the existing GitHub issue rather than creating a new one.
76
129
 
130
+ ## GitHub Webhook (Auto-Close)
131
+
132
+ When a developer fixes a bug and closes the GitHub issue, Snitch can automatically close the matching event record in your database — no manual dashboard cleanup required.
133
+
134
+ ### Setup
135
+
136
+ 1. Generate a webhook secret:
137
+
138
+ ```bash
139
+ ruby -rsecurerandom -e "puts SecureRandom.hex(32)"
140
+ ```
141
+
142
+ 2. Add the secret to your environment and Snitch config:
143
+
144
+ ```bash
145
+ export SNITCH_GITHUB_WEBHOOK_SECRET="your-generated-secret"
146
+ ```
147
+
148
+ ```ruby
149
+ # config/initializers/snitch.rb
150
+ config.github_webhook_secret = ENV["SNITCH_GITHUB_WEBHOOK_SECRET"]
151
+ ```
152
+
153
+ 3. Add the same secret to your GitHub repository. Go to **Settings > Webhooks > Add webhook** and configure:
154
+
155
+ | Field | Value |
156
+ |----------------|------------------------------------------------|
157
+ | Payload URL | `https://yourapp.com/snitches/webhooks/github` |
158
+ | Content type | `application/json` |
159
+ | Secret | The secret you generated in step 1 |
160
+ | Events | Select **Issues** only |
161
+
162
+ The secret must match on both sides — your app uses it to verify that incoming webhooks are genuinely from GitHub.
163
+
164
+ Once configured, closing a GitHub issue will automatically close the corresponding Snitch event.
165
+
77
166
  ## How It Works
78
167
 
79
168
  1. Rack middleware catches any unhandled exception (and re-raises it so normal error handling still applies)
@@ -84,9 +173,9 @@ The same fingerprinting and deduplication rules apply. If the same exception is
84
173
 
85
174
  ## Roadmap
86
175
 
87
- - [ ] multi-db support
88
- - [ ] simple dashboard to view captured exceptions (linked to gh issue)
89
- - [ ] webhook to resolve snitch record, when issues resolve
176
+ - [x] Dashboard to view and manage captured exceptions
177
+ - [ ] Multi-db support
178
+ - [x] Webhook to resolve snitch records when GitHub issues close
90
179
 
91
180
  ## Requirements
92
181
 
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-green-50:oklch(98.2% .018 155.826);--color-green-200:oklch(92.5% .084 155.995);--color-green-500:oklch(72.3% .219 149.579);--color-green-700:oklch(52.7% .154 150.069);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-lg:.5rem;--radius-xl:.75rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.end{inset-inline-end:var(--spacing)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-5{height:calc(var(--spacing) * 5)}.h-7{height:calc(var(--spacing) * 7)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-12{width:calc(var(--spacing) * 12)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[14rem\]{max-width:14rem}.max-w-xs{max-width:var(--container-xs)}.min-w-\[2rem\]{min-width:2rem}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-x-1{column-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-amber-300{border-color:var(--color-amber-300)}.border-amber-500{border-color:var(--color-amber-500)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-500{border-color:var(--color-gray-500)}.border-green-200{border-color:var(--color-green-200)}.border-indigo-300{border-color:var(--color-indigo-300)}.border-indigo-500{border-color:var(--color-indigo-500)}.border-red-200{border-color:var(--color-red-200)}.border-transparent{border-color:#0000}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-50{background-color:var(--color-green-50)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-12{padding:calc(var(--spacing) * 12)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-8{padding-block:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-700{color:var(--color-green-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-500{color:var(--color-red-500)}.text-red-700{color:var(--color-red-700)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-indigo-100:hover{background-color:var(--color-indigo-100)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-800:hover{color:var(--color-indigo-800)}}@media (min-width:40rem){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:64rem){.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}
@@ -0,0 +1,2 @@
1
+ @import "tailwindcss";
2
+ @source "../../../views/**/*.erb";
@@ -0,0 +1,35 @@
1
+ module Snitch
2
+ class SnitchesController < ActionController::Base
3
+ layout "snitch/application"
4
+
5
+ def index
6
+ @tab = params[:tab] || "open"
7
+ @events = case @tab
8
+ when "open" then Snitch::Event.open.order(last_occurred_at: :desc)
9
+ when "closed" then Snitch::Event.closed.order(updated_at: :desc)
10
+ when "ignored" then Snitch::Event.ignored.order(updated_at: :desc)
11
+ else Snitch::Event.open.order(last_occurred_at: :desc)
12
+ end
13
+ @counts = {
14
+ open: Snitch::Event.open.count,
15
+ closed: Snitch::Event.closed.count,
16
+ ignored: Snitch::Event.ignored.count
17
+ }
18
+ end
19
+
20
+ def show
21
+ @event = Snitch::Event.find(params[:id])
22
+ end
23
+
24
+ def update
25
+ @event = Snitch::Event.find(params[:id])
26
+ @event.update!(status: params[:status])
27
+
28
+ if params[:redirect_to] == "show"
29
+ redirect_to snitch_path(@event), notice: "Snitch updated."
30
+ else
31
+ redirect_to snitches_path(tab: params[:tab] || @event.status), notice: "Snitch updated."
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitch
4
+ class WebhooksController < ActionController::Base
5
+ skip_forgery_protection
6
+
7
+ def github
8
+ secret = Snitch.configuration.github_webhook_secret
9
+ return head :unauthorized unless secret.present?
10
+
11
+ body = request.body.read
12
+ signature = request.headers["X-Hub-Signature-256"]
13
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", secret, body)}"
14
+ return head :unauthorized unless signature.present? && Rack::Utils.secure_compare(expected, signature)
15
+
16
+ return head :ok unless request.headers["X-GitHub-Event"] == "issues"
17
+
18
+ payload = JSON.parse(body)
19
+ return head :ok unless payload["action"] == "closed"
20
+
21
+ issue_number = payload.dig("issue", "number")
22
+ ResolveEventJob.perform_later(issue_number)
23
+
24
+ head :ok
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,51 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Snitch</title>
7
+ <style>
8
+ <%= File.read(Snitch::Engine.root.join("app/assets/builds/snitch/application.css")).html_safe %>
9
+ </style>
10
+ </head>
11
+ <body class="bg-gray-50 min-h-screen">
12
+ <header class="bg-white border-b border-gray-200">
13
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
14
+ <div class="flex items-center justify-between h-16">
15
+ <div class="flex items-center gap-3">
16
+ <svg class="w-7 h-7 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
17
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
18
+ </svg>
19
+ <%= link_to "Snitch", snitches_path, class: "text-xl font-bold text-gray-900 hover:text-indigo-600 transition-colors" %>
20
+ </div>
21
+ </div>
22
+ </div>
23
+ </header>
24
+
25
+ <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
26
+ <% if notice %>
27
+ <div class="mb-6 rounded-lg bg-green-50 border border-green-200 p-4">
28
+ <div class="flex items-center">
29
+ <svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
30
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
31
+ </svg>
32
+ <p class="text-sm text-green-700"><%= notice %></p>
33
+ </div>
34
+ </div>
35
+ <% end %>
36
+
37
+ <% if alert %>
38
+ <div class="mb-6 rounded-lg bg-red-50 border border-red-200 p-4">
39
+ <div class="flex items-center">
40
+ <svg class="w-5 h-5 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
41
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
42
+ </svg>
43
+ <p class="text-sm text-red-700"><%= alert %></p>
44
+ </div>
45
+ </div>
46
+ <% end %>
47
+
48
+ <%= yield %>
49
+ </main>
50
+ </body>
51
+ </html>
@@ -0,0 +1,125 @@
1
+ <div>
2
+ <div class="border-b border-gray-200 mb-6">
3
+ <nav class="flex gap-x-1" aria-label="Tabs">
4
+ <% [
5
+ ["open", "Open", @counts[:open], "indigo"],
6
+ ["closed", "Closed", @counts[:closed], "gray"],
7
+ ["ignored", "Ignored", @counts[:ignored], "amber"]
8
+ ].each do |tab_key, tab_label, count, color| %>
9
+ <% active = @tab == tab_key %>
10
+ <%= link_to snitches_path(tab: tab_key),
11
+ class: "flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors #{
12
+ if active
13
+ case color
14
+ when 'indigo' then 'border-indigo-500 text-indigo-600'
15
+ when 'gray' then 'border-gray-500 text-gray-700'
16
+ when 'amber' then 'border-amber-500 text-amber-600'
17
+ end
18
+ else
19
+ 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
20
+ end
21
+ }" do %>
22
+ <%= tab_label %>
23
+ <span class="rounded-full px-2.5 py-0.5 text-xs font-medium <%=
24
+ if active
25
+ case color
26
+ when 'indigo' then 'bg-indigo-100 text-indigo-700'
27
+ when 'gray' then 'bg-gray-100 text-gray-700'
28
+ when 'amber' then 'bg-amber-100 text-amber-700'
29
+ end
30
+ else
31
+ 'bg-gray-100 text-gray-600'
32
+ end
33
+ %>"><%= count %></span>
34
+ <% end %>
35
+ <% end %>
36
+ </nav>
37
+ </div>
38
+
39
+ <% if @events.any? %>
40
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-x-auto">
41
+ <table class="w-full divide-y divide-gray-200">
42
+ <thead class="bg-gray-50">
43
+ <tr>
44
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Exception</th>
45
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Message</th>
46
+ <th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">Count</th>
47
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Last Occurred</th>
48
+ <th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Issue</th>
49
+ <th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
50
+ </tr>
51
+ </thead>
52
+ <tbody class="divide-y divide-gray-200">
53
+ <% @events.each do |event| %>
54
+ <tr class="hover:bg-gray-50 transition-colors">
55
+ <td class="px-6 py-4 max-w-[14rem]">
56
+ <%= link_to snitch_path(event), class: "font-mono text-sm font-semibold text-indigo-600 hover:text-indigo-800 truncate block" do %>
57
+ <%= truncate(event.exception_class, length: 30) %>
58
+ <% end %>
59
+ </td>
60
+ <td class="px-6 py-4 max-w-xs">
61
+ <p class="text-sm text-gray-600 truncate"><%= truncate(event.message, length: 80) %></p>
62
+ </td>
63
+ <td class="px-6 py-4 text-center">
64
+ <span class="inline-flex items-center justify-center min-w-[2rem] rounded-full px-2.5 py-0.5 text-xs font-semibold <%= event.occurrence_count > 10 ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700' %>">
65
+ <%= event.occurrence_count %>
66
+ </span>
67
+ </td>
68
+ <td class="px-6 py-4 whitespace-nowrap">
69
+ <span class="text-sm text-gray-500"><%= event.last_occurred_at&.strftime("%b %d, %Y %l:%M %p") %></span>
70
+ </td>
71
+ <td class="px-6 py-4 whitespace-nowrap">
72
+ <% if event.github_issue_url.present? %>
73
+ <a href="<%= event.github_issue_url %>" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-800 font-medium">
74
+ #<%= event.github_issue_number %>
75
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
76
+ </a>
77
+ <% else %>
78
+ <span class="text-sm text-gray-400">&mdash;</span>
79
+ <% end %>
80
+ </td>
81
+ <td class="px-6 py-4 whitespace-nowrap text-right">
82
+ <div class="flex items-center justify-end gap-2">
83
+ <% if @tab == "open" %>
84
+ <%= form_with url: snitch_path(event), method: :patch, local: true, class: "inline" do |f| %>
85
+ <%= f.hidden_field :status, value: "closed" %>
86
+ <%= f.hidden_field :tab, value: @tab %>
87
+ <%= f.submit "Close", class: "inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 transition-colors cursor-pointer" %>
88
+ <% end %>
89
+ <%= form_with url: snitch_path(event), method: :patch, local: true, class: "inline" do |f| %>
90
+ <%= f.hidden_field :status, value: "ignored" %>
91
+ <%= f.hidden_field :tab, value: @tab %>
92
+ <%= f.submit "Ignore", class: "inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg border border-amber-300 text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors cursor-pointer" %>
93
+ <% end %>
94
+ <% else %>
95
+ <%= form_with url: snitch_path(event), method: :patch, local: true, class: "inline" do |f| %>
96
+ <%= f.hidden_field :status, value: "open" %>
97
+ <%= f.hidden_field :tab, value: @tab %>
98
+ <%= f.submit "Reopen", class: "inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg border border-indigo-300 text-indigo-700 bg-indigo-50 hover:bg-indigo-100 transition-colors cursor-pointer" %>
99
+ <% end %>
100
+ <% end %>
101
+ </div>
102
+ </td>
103
+ </tr>
104
+ <% end %>
105
+ </tbody>
106
+ </table>
107
+ </div>
108
+ <% else %>
109
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
110
+ <svg class="mx-auto w-12 h-12 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
111
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
112
+ </svg>
113
+ <h3 class="text-sm font-semibold text-gray-900 mb-1">No <%= @tab %> exceptions</h3>
114
+ <p class="text-sm text-gray-500">
115
+ <% if @tab == "open" %>
116
+ All clear! No unhandled exceptions to review.
117
+ <% elsif @tab == "closed" %>
118
+ No exceptions have been closed yet.
119
+ <% else %>
120
+ No exceptions are being ignored.
121
+ <% end %>
122
+ </p>
123
+ </div>
124
+ <% end %>
125
+ </div>
@@ -0,0 +1,99 @@
1
+ <div>
2
+ <div class="mb-6">
3
+ <%= link_to "&larr; Back to list".html_safe, snitches_path(tab: @event.status), class: "text-sm text-gray-500 hover:text-gray-700 transition-colors" %>
4
+ </div>
5
+
6
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
7
+ <div class="px-6 py-5 border-b border-gray-200 flex items-center justify-between">
8
+ <div>
9
+ <h1 class="font-mono text-lg font-bold text-gray-900"><%= @event.exception_class %></h1>
10
+ <p class="text-sm text-gray-500 mt-1"><%= @event.message %></p>
11
+ </div>
12
+ <span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold <%=
13
+ case @event.status
14
+ when 'open' then 'bg-indigo-100 text-indigo-700'
15
+ when 'closed' then 'bg-gray-100 text-gray-700'
16
+ when 'ignored' then 'bg-amber-100 text-amber-700'
17
+ end
18
+ %>"><%= @event.status.capitalize %></span>
19
+ </div>
20
+
21
+ <div class="px-6 py-5 border-b border-gray-200">
22
+ <div class="grid grid-cols-2 gap-6 sm:grid-cols-4">
23
+ <div>
24
+ <dt class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Occurrences</dt>
25
+ <dd class="mt-1 text-sm font-semibold text-gray-900"><%= @event.occurrence_count %></dd>
26
+ </div>
27
+ <div>
28
+ <dt class="text-xs font-semibold text-gray-500 uppercase tracking-wider">First seen</dt>
29
+ <dd class="mt-1 text-sm text-gray-900"><%= @event.first_occurred_at&.strftime("%b %d, %Y %l:%M %p") %></dd>
30
+ </div>
31
+ <div>
32
+ <dt class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Last seen</dt>
33
+ <dd class="mt-1 text-sm text-gray-900"><%= @event.last_occurred_at&.strftime("%b %d, %Y %l:%M %p") %></dd>
34
+ </div>
35
+ <div>
36
+ <dt class="text-xs font-semibold text-gray-500 uppercase tracking-wider">GitHub issue</dt>
37
+ <dd class="mt-1 text-sm">
38
+ <% if @event.github_issue_url.present? %>
39
+ <a href="<%= @event.github_issue_url %>" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-800 font-medium">
40
+ #<%= @event.github_issue_number %>
41
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
42
+ </a>
43
+ <% else %>
44
+ <span class="text-gray-400">&mdash;</span>
45
+ <% end %>
46
+ </dd>
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <% if @event.request_url.present? %>
52
+ <div class="px-6 py-5 border-b border-gray-200">
53
+ <h2 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Request</h2>
54
+ <div class="text-sm text-gray-900">
55
+ <span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-semibold bg-gray-100 text-gray-700 mr-2"><%= @event.request_method %></span>
56
+ <span class="font-mono text-sm"><%= @event.request_url %></span>
57
+ </div>
58
+ <% if @event.request_params.present? %>
59
+ <pre class="mt-3 bg-gray-50 rounded-lg p-4 text-xs font-mono text-gray-700 overflow-x-auto"><%= JSON.pretty_generate(JSON.parse(@event.request_params)) rescue @event.request_params %></pre>
60
+ <% end %>
61
+ </div>
62
+ <% end %>
63
+
64
+ <% if @event.backtrace.present? %>
65
+ <div class="px-6 py-5 border-b border-gray-200">
66
+ <h2 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Backtrace</h2>
67
+ <div class="bg-gray-50 rounded-lg p-4 overflow-x-auto">
68
+ <% @event.backtrace.each_with_index do |line, i| %>
69
+ <div class="flex gap-3 py-0.5 text-xs font-mono <%= line.include?('/app/') ? 'text-gray-900 font-medium' : 'text-gray-400' %>">
70
+ <span class="text-gray-300 select-none w-6 text-right shrink-0"><%= i + 1 %></span>
71
+ <span class="break-all"><%= line %></span>
72
+ </div>
73
+ <% end %>
74
+ </div>
75
+ </div>
76
+ <% end %>
77
+
78
+ <div class="px-6 py-5 flex items-center gap-3">
79
+ <% if @event.status == "open" %>
80
+ <%= form_with url: snitch_path(@event), method: :patch, local: true, class: "inline" do |f| %>
81
+ <%= f.hidden_field :status, value: "closed" %>
82
+ <%= f.hidden_field :redirect_to, value: "show" %>
83
+ <%= f.submit "Close", class: "inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 transition-colors cursor-pointer" %>
84
+ <% end %>
85
+ <%= form_with url: snitch_path(@event), method: :patch, local: true, class: "inline" do |f| %>
86
+ <%= f.hidden_field :status, value: "ignored" %>
87
+ <%= f.hidden_field :redirect_to, value: "show" %>
88
+ <%= f.submit "Ignore", class: "inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg border border-amber-300 text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors cursor-pointer" %>
89
+ <% end %>
90
+ <% else %>
91
+ <%= form_with url: snitch_path(@event), method: :patch, local: true, class: "inline" do |f| %>
92
+ <%= f.hidden_field :status, value: "open" %>
93
+ <%= f.hidden_field :redirect_to, value: "show" %>
94
+ <%= f.submit "Reopen", class: "inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg border border-indigo-300 text-indigo-700 bg-indigo-50 hover:bg-indigo-100 transition-colors cursor-pointer" %>
95
+ <% end %>
96
+ <% end %>
97
+ </div>
98
+ </div>
99
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Snitch::Engine.routes.draw do
2
+ resources :snitches, only: [:index, :show, :update]
3
+ post "webhooks/github", to: "webhooks#github"
4
+ root to: "snitches#index"
5
+ end
@@ -33,6 +33,10 @@ module Snitch
33
33
  def create_initializer
34
34
  template "snitch.rb", "config/initializers/snitch.rb"
35
35
  end
36
+
37
+ def add_route
38
+ route 'mount Snitch::Engine, at: "/snitches"'
39
+ end
36
40
  end
37
41
  end
38
42
  end
@@ -11,6 +11,7 @@ class CreateSnitchErrors < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
11
11
  t.integer :occurrence_count, default: 1
12
12
  t.integer :github_issue_number
13
13
  t.string :github_issue_url
14
+ t.string :status, default: "open", null: false
14
15
  t.datetime :first_occurred_at
15
16
  t.datetime :last_occurred_at
16
17
  t.timestamps
@@ -18,5 +19,6 @@ class CreateSnitchErrors < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
18
19
 
19
20
  add_index :snitch_errors, :fingerprint, unique: true
20
21
  add_index :snitch_errors, :exception_class
22
+ add_index :snitch_errors, :status
21
23
  end
22
24
  end
@@ -13,4 +13,7 @@ Snitch.configure do |config|
13
13
 
14
14
  # Exceptions to ignore (default: RecordNotFound, RoutingError)
15
15
  # config.ignored_exceptions += [CustomError]
16
+
17
+ # GitHub webhook secret for auto-closing events when issues are closed
18
+ # config.github_webhook_secret = ENV["SNITCH_GITHUB_WEBHOOK_SECRET"]
16
19
  end
@@ -0,0 +1,6 @@
1
+ class AddStatusToSnitchErrors < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :snitch_errors, :status, :string, default: "open", null: false
4
+ add_index :snitch_errors, :status
5
+ end
6
+ end
@@ -0,0 +1,17 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Snitch
5
+ module Generators
6
+ class UpdateGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def create_migration_file
12
+ migration_template "add_status_to_snitch_errors.rb.erb",
13
+ "db/migrate/add_status_to_snitch_errors.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -2,11 +2,12 @@
2
2
 
3
3
  module Snitch
4
4
  class Configuration
5
- attr_accessor :github_token, :github_repo, :mention, :enabled, :ignored_exceptions
5
+ attr_accessor :github_token, :github_repo, :github_webhook_secret, :mention, :enabled, :ignored_exceptions
6
6
 
7
7
  def initialize
8
8
  @github_token = nil
9
9
  @github_repo = nil
10
+ @github_webhook_secret = nil
10
11
  @mention = "@claude"
11
12
  @enabled = true
12
13
  @ignored_exceptions = [
@@ -16,10 +16,13 @@ module Snitch
16
16
  private
17
17
 
18
18
  def ignored?(exception)
19
- Snitch.configuration.ignored_exceptions.any? do |ignored|
19
+ config_ignored = Snitch.configuration.ignored_exceptions.any? do |ignored|
20
20
  ignored_class = ignored.is_a?(String) ? ignored.safe_constantize : ignored
21
21
  ignored_class && exception.is_a?(ignored_class)
22
22
  end
23
+ return true if config_ignored
24
+
25
+ Snitch::Event.ignored.where(exception_class: exception.class.name).exists?
23
26
  end
24
27
 
25
28
  def extract_request_data(env)
@@ -42,12 +45,14 @@ module Snitch
42
45
  existing = Event.find_by(fingerprint: fingerprint)
43
46
 
44
47
  if existing
45
- existing.update!(
48
+ attrs = {
46
49
  occurrence_count: existing.occurrence_count + 1,
47
50
  last_occurred_at: Time.current,
48
51
  message: exception.message,
49
52
  backtrace: exception.backtrace
50
- )
53
+ }
54
+ attrs[:status] = "open" if existing.status == "closed"
55
+ existing.update!(**attrs)
51
56
  existing
52
57
  else
53
58
  Event.create!(
@@ -66,7 +71,7 @@ module Snitch
66
71
  def enqueue_report(record)
67
72
  ReportExceptionJob.perform_later(record.id)
68
73
  rescue => e
69
- Rails.logger.error("[Snitch] Failed to enqueue report job: #{e.message}") if defined?(Rails)
74
+ Rails.logger.error("[Snitch] Failed to enqueue report job: #{e.message}") if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
70
75
  end
71
76
  end
72
77
  end
@@ -24,6 +24,11 @@ module Snitch
24
24
  def comment_on_issue(event)
25
25
  body = build_comment_body(event)
26
26
  @client.add_comment(@repo, event.github_issue_number, body)
27
+ reopen_issue(event) if event.status == "open"
28
+ end
29
+
30
+ def reopen_issue(event)
31
+ @client.update_issue(@repo, event.github_issue_number, state: "open")
27
32
  end
28
33
 
29
34
  private
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snitch
4
+ class ResolveEventJob < ActiveJob::Base
5
+ queue_as :default
6
+
7
+ retry_on StandardError, wait: :polynomially_later, attempts: 3
8
+
9
+ def perform(github_issue_number)
10
+ Event.where(github_issue_number: github_issue_number, status: "open")
11
+ .update_all(status: "closed")
12
+ end
13
+ end
14
+ end
@@ -10,7 +10,7 @@ module Snitch
10
10
  begin
11
11
  ExceptionHandler.handle(e, env)
12
12
  rescue => handler_error
13
- Rails.logger.error("[Snitch] Handler error: #{handler_error.message}") if defined?(Rails)
13
+ Rails.logger.error("[Snitch] Handler error: #{handler_error.message}") if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
14
14
  end
15
15
  raise e
16
16
  end
@@ -2,12 +2,18 @@ module Snitch
2
2
  class Event < ActiveRecord::Base
3
3
  self.table_name = "snitch_errors"
4
4
 
5
+ STATUSES = %w[open closed ignored].freeze
6
+
5
7
  serialize :backtrace, coder: JSON
6
8
  serialize :request_params, coder: JSON
7
9
 
8
10
  validates :exception_class, presence: true
9
11
  validates :fingerprint, presence: true
12
+ validates :status, inclusion: { in: STATUSES }
10
13
 
11
14
  scope :by_fingerprint, ->(fp) { where(fingerprint: fp) }
15
+ scope :open, -> { where(status: "open") }
16
+ scope :closed, -> { where(status: "closed") }
17
+ scope :ignored, -> { where(status: "ignored") }
12
18
  end
13
19
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Snitch
4
- VERSION = '0.2.4'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/snitch.rb CHANGED
@@ -8,6 +8,7 @@ require "snitch/exception_handler"
8
8
  require "snitch/github_client"
9
9
  require "snitch/models/event" if defined?(ActiveRecord)
10
10
  require "snitch/jobs/report_exception_job" if defined?(ActiveJob)
11
+ require "snitch/jobs/resolve_event_job" if defined?(ActiveJob)
11
12
  require "snitch/engine" if defined?(Rails)
12
13
 
13
14
  module Snitch
@@ -0,0 +1,22 @@
1
+ namespace :snitch do
2
+ namespace :tailwind do
3
+ desc "Build Snitch Tailwind CSS"
4
+ task :build do
5
+ require "tailwindcss/ruby"
6
+
7
+ gem_root = File.expand_path("../..", __dir__)
8
+ input = File.join(gem_root, "app/assets/stylesheets/snitch/application.css")
9
+ output = File.join(gem_root, "app/assets/builds/snitch/application.css")
10
+
11
+ FileUtils.mkdir_p(File.dirname(output))
12
+
13
+ system(Tailwindcss::Ruby.executable,
14
+ "-i", input,
15
+ "-o", output,
16
+ "--minify",
17
+ exception: true)
18
+
19
+ puts "Snitch Tailwind CSS compiled successfully"
20
+ end
21
+ end
22
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: snitch-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - RiseKit
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '9.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: tailwindcss-ruby
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '4.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '4.0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: rspec-rails
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -115,9 +129,19 @@ extra_rdoc_files: []
115
129
  files:
116
130
  - LICENSE.txt
117
131
  - README.md
132
+ - app/assets/builds/snitch/application.css
133
+ - app/assets/stylesheets/snitch/application.css
134
+ - app/controllers/snitch/snitches_controller.rb
135
+ - app/controllers/snitch/webhooks_controller.rb
136
+ - app/views/layouts/snitch/application.html.erb
137
+ - app/views/snitch/snitches/index.html.erb
138
+ - app/views/snitch/snitches/show.html.erb
139
+ - config/routes.rb
118
140
  - lib/generators/snitch/install/install_generator.rb
119
141
  - lib/generators/snitch/install/templates/create_snitch_errors.rb.erb
120
142
  - lib/generators/snitch/install/templates/snitch.rb
143
+ - lib/generators/snitch/update/templates/add_status_to_snitch_errors.rb.erb
144
+ - lib/generators/snitch/update/update_generator.rb
121
145
  - lib/snitch.rb
122
146
  - lib/snitch/configuration.rb
123
147
  - lib/snitch/engine.rb
@@ -125,9 +149,11 @@ files:
125
149
  - lib/snitch/fingerprint.rb
126
150
  - lib/snitch/github_client.rb
127
151
  - lib/snitch/jobs/report_exception_job.rb
152
+ - lib/snitch/jobs/resolve_event_job.rb
128
153
  - lib/snitch/middleware.rb
129
154
  - lib/snitch/models/event.rb
130
155
  - lib/snitch/version.rb
156
+ - lib/tasks/snitch_tailwind.rake
131
157
  licenses:
132
158
  - MIT
133
159
  metadata: {}