rails_error_dashboard 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +12 -1
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +35 -1
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +1 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +13 -2
- data/lib/rails_error_dashboard/commands/create_issue.rb +1 -1
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +3 -1
- data/lib/rails_error_dashboard/configuration.rb +13 -7
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +8 -5
- data/lib/rails_error_dashboard/services/linear_issue_client.rb +248 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +1 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 532921b0ba2ccb40a532a66e5e3c7b1fcf17fc56e53cb5b0ed3772648644f0c0
|
|
4
|
+
data.tar.gz: 282169f161d90326aaebce5933521cd54c8bb373611e9002b447f114b6654204
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63c2ba5ef4319eaff8450837fec526135e37865ea0d2c72c30b7010ff6e86c03e49f728172d032bec6ad626f8dba39d00841300839fee9b8a493caa912669559
|
|
7
|
+
data.tar.gz: c84238b00e2f6959fd1e4ac5a1c9ff2d696a0d28379a102e99d761585caf4c24a4f6446f4d303d69c933e5135b31e7c3ef1d0a6b7464376e88ad25b14068c75a
|
data/README.md
CHANGED
|
@@ -233,7 +233,7 @@ Payload contract matches the `LlmCallEvent` value object — see [`docs/LLM_OBSE
|
|
|
233
233
|
</details>
|
|
234
234
|
|
|
235
235
|
<details>
|
|
236
|
-
<summary><strong>Issue Tracking — GitHub, GitLab, Codeberg</strong></summary>
|
|
236
|
+
<summary><strong>Issue Tracking — GitHub, GitLab, Codeberg, Linear</strong></summary>
|
|
237
237
|
|
|
238
238
|
One switch connects errors to your issue tracker. Platform becomes the source of truth — status, assignees, labels, and comments are mirrored live in the dashboard.
|
|
239
239
|
|
|
@@ -250,6 +250,17 @@ config.issue_tracker_token = ENV["RED_BOT_TOKEN"]
|
|
|
250
250
|
# That's it — provider and repo auto-detected from git_repository_url
|
|
251
251
|
```
|
|
252
252
|
|
|
253
|
+
Linear works too — it's not a git forge, so set the provider and team key explicitly:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
config.enable_issue_tracking = true
|
|
257
|
+
config.issue_tracker_provider = :linear
|
|
258
|
+
config.issue_tracker_repo = "ENG" # Linear team key (issues land as ENG-123)
|
|
259
|
+
config.issue_tracker_token = ENV["RED_BOT_TOKEN"] # lin_api_... personal API key
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Closing maps to the team's first `completed` workflow state, reopening to `unstarted`/`backlog`. Two-way sync uses Linear webhooks (`Linear-Signature` HMAC verification).
|
|
263
|
+
|
|
253
264
|
[Complete documentation →](docs/guides/CONFIGURATION.md)
|
|
254
265
|
</details>
|
|
255
266
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
|
-
# Receives webhooks from GitHub/GitLab/Codeberg for two-way issue sync.
|
|
4
|
+
# Receives webhooks from GitHub/GitLab/Codeberg/Linear for two-way issue sync.
|
|
5
5
|
#
|
|
6
6
|
# When an issue is closed/reopened on the platform, the corresponding
|
|
7
7
|
# error in the dashboard is resolved/reopened to match.
|
|
@@ -10,6 +10,7 @@ module RailsErrorDashboard
|
|
|
10
10
|
# - GitHub: X-Hub-Signature-256 (HMAC-SHA256)
|
|
11
11
|
# - GitLab: X-Gitlab-Token (shared secret)
|
|
12
12
|
# - Codeberg: X-Gitea-Signature (HMAC-SHA256)
|
|
13
|
+
# - Linear: Linear-Signature (HMAC-SHA256)
|
|
13
14
|
class WebhooksController < ActionController::Base
|
|
14
15
|
skip_before_action :verify_authenticity_token
|
|
15
16
|
|
|
@@ -29,6 +30,8 @@ module RailsErrorDashboard
|
|
|
29
30
|
handle_gitlab(payload)
|
|
30
31
|
when "codeberg"
|
|
31
32
|
handle_codeberg(payload)
|
|
33
|
+
when "linear"
|
|
34
|
+
handle_linear(payload)
|
|
32
35
|
else
|
|
33
36
|
head :not_found
|
|
34
37
|
return
|
|
@@ -69,6 +72,8 @@ module RailsErrorDashboard
|
|
|
69
72
|
verify_gitlab_token(secret)
|
|
70
73
|
when "codeberg"
|
|
71
74
|
verify_codeberg_signature(body, secret)
|
|
75
|
+
when "linear"
|
|
76
|
+
verify_linear_signature(body, secret)
|
|
72
77
|
else
|
|
73
78
|
false
|
|
74
79
|
end
|
|
@@ -99,6 +104,14 @@ module RailsErrorDashboard
|
|
|
99
104
|
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
100
105
|
end
|
|
101
106
|
|
|
107
|
+
def verify_linear_signature(body, secret)
|
|
108
|
+
signature = request.headers["Linear-Signature"]
|
|
109
|
+
return false unless signature
|
|
110
|
+
|
|
111
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
|
|
112
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
113
|
+
end
|
|
114
|
+
|
|
102
115
|
def parse_payload
|
|
103
116
|
JSON.parse(request.body.read)
|
|
104
117
|
rescue JSON::ParserError
|
|
@@ -162,6 +175,27 @@ module RailsErrorDashboard
|
|
|
162
175
|
end
|
|
163
176
|
end
|
|
164
177
|
|
|
178
|
+
# Linear: Issue webhook fires with action create/update/remove. State changes
|
|
179
|
+
# arrive as updates with the old stateId in updatedFrom. Linear has no
|
|
180
|
+
# open/closed binary — completed/canceled state types map to resolved.
|
|
181
|
+
def handle_linear(payload)
|
|
182
|
+
return unless payload["type"] == "Issue" && payload["action"] == "update"
|
|
183
|
+
return unless payload["updatedFrom"]&.key?("stateId")
|
|
184
|
+
|
|
185
|
+
issue_number = payload.dig("data", "number")
|
|
186
|
+
return unless issue_number
|
|
187
|
+
|
|
188
|
+
error = find_error_by_issue(issue_number, "linear")
|
|
189
|
+
return unless error
|
|
190
|
+
|
|
191
|
+
state_type = payload.dig("data", "state", "type")
|
|
192
|
+
if state_type.in?(%w[completed canceled])
|
|
193
|
+
resolve_error(error, "Completed on Linear by #{payload.dig("actor", "name")}")
|
|
194
|
+
elsif state_type.in?(%w[triage backlog unstarted started])
|
|
195
|
+
reopen_error(error)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
165
199
|
def find_error_by_issue(issue_number, provider)
|
|
166
200
|
ErrorLog.find_by(
|
|
167
201
|
external_issue_number: issue_number,
|
|
@@ -519,7 +519,7 @@ RailsErrorDashboard.configure do |config|
|
|
|
519
519
|
# Safe to enable on production OTel pipelines.
|
|
520
520
|
|
|
521
521
|
# ============================================================================
|
|
522
|
-
# ISSUE TRACKING (GitHub / GitLab / Codeberg)
|
|
522
|
+
# ISSUE TRACKING (GitHub / GitLab / Codeberg / Linear)
|
|
523
523
|
# ============================================================================
|
|
524
524
|
#
|
|
525
525
|
# One switch enables everything: issue creation, auto-create on first
|
|
@@ -534,7 +534,7 @@ RailsErrorDashboard.configure do |config|
|
|
|
534
534
|
# - Priority → labels from platform (with colors)
|
|
535
535
|
# - Snooze and Mute remain (no platform equivalent)
|
|
536
536
|
#
|
|
537
|
-
# Setup:
|
|
537
|
+
# Setup (GitHub/GitLab/Codeberg):
|
|
538
538
|
# 1. Create a RED bot account on GitHub/GitLab/Codeberg
|
|
539
539
|
# 2. Generate a token and set RED_BOT_TOKEN env var
|
|
540
540
|
# 3. Set git_repository_url above (already used for source code linking)
|
|
@@ -543,6 +543,17 @@ RailsErrorDashboard.configure do |config|
|
|
|
543
543
|
# config.enable_issue_tracking = true
|
|
544
544
|
# config.issue_tracker_token = ENV["RED_BOT_TOKEN"]
|
|
545
545
|
#
|
|
546
|
+
# Setup (Linear):
|
|
547
|
+
# Linear is not a git forge, so it cannot be auto-detected from
|
|
548
|
+
# git_repository_url — set provider and team key explicitly. Issues are
|
|
549
|
+
# created in the team matching the key (e.g. "ENG" for ENG-123 issues).
|
|
550
|
+
# Generate a personal API key under Settings > Security & access.
|
|
551
|
+
#
|
|
552
|
+
# config.enable_issue_tracking = true
|
|
553
|
+
# config.issue_tracker_provider = :linear
|
|
554
|
+
# config.issue_tracker_repo = "ENG" # Linear team key
|
|
555
|
+
# config.issue_tracker_token = ENV["RED_BOT_TOKEN"] # lin_api_... key
|
|
556
|
+
#
|
|
546
557
|
# Optional overrides:
|
|
547
558
|
# config.issue_tracker_labels = ["bug"] # Labels added to new issues
|
|
548
559
|
# config.issue_tracker_auto_create_severities = [:critical, :high] # Auto-create threshold
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
module Commands
|
|
5
|
-
# Command: Create an issue on the configured issue tracker (GitHub/GitLab/Codeberg)
|
|
5
|
+
# Command: Create an issue on the configured issue tracker (GitHub/GitLab/Codeberg/Linear)
|
|
6
6
|
#
|
|
7
7
|
# Creates the issue via the provider API, then stores the issue URL, number,
|
|
8
8
|
# and provider on the error record for linking.
|
|
@@ -14,7 +14,9 @@ module RailsErrorDashboard
|
|
|
14
14
|
PROVIDER_PATTERNS = {
|
|
15
15
|
github: %r{github\.com/([^/]+/[^/]+)/issues/(\d+)}i,
|
|
16
16
|
gitlab: %r{gitlab\.com/([^/]+/[^/]+)/-/issues/(\d+)}i,
|
|
17
|
-
codeberg: %r{codeberg\.org/([^/]+/[^/]+)/issues/(\d+)}i
|
|
17
|
+
codeberg: %r{codeberg\.org/([^/]+/[^/]+)/issues/(\d+)}i,
|
|
18
|
+
# https://linear.app/<workspace>/issue/ENG-123/<slug> — capture team key + number
|
|
19
|
+
linear: %r{linear\.app/[^/]+/issue/([A-Za-z][A-Za-z0-9]*)-(\d+)}i
|
|
18
20
|
}.freeze
|
|
19
21
|
|
|
20
22
|
def self.call(error_id, issue_url:)
|
|
@@ -92,8 +92,8 @@ module RailsErrorDashboard
|
|
|
92
92
|
# issue_webhook_secret is set.
|
|
93
93
|
attr_accessor :enable_issue_tracking # Master switch (default: false) — enables all platform integration
|
|
94
94
|
attr_accessor :issue_tracker_token # String or lambda/proc for Rails credentials
|
|
95
|
-
attr_accessor :issue_tracker_provider # :github, :gitlab, :codeberg (auto-detected from git_repository_url)
|
|
96
|
-
attr_accessor :issue_tracker_repo # "owner/repo" (auto-extracted from git_repository_url)
|
|
95
|
+
attr_accessor :issue_tracker_provider # :github, :gitlab, :codeberg (auto-detected from git_repository_url), or :linear (explicit only)
|
|
96
|
+
attr_accessor :issue_tracker_repo # "owner/repo" (auto-extracted from git_repository_url), or Linear team key like "ENG"
|
|
97
97
|
attr_accessor :issue_tracker_labels # Array of label strings (default: ["bug"])
|
|
98
98
|
attr_accessor :issue_tracker_api_url # Custom API base URL for self-hosted instances
|
|
99
99
|
attr_accessor :issue_tracker_auto_create_severities # Auto-create for these severities (default: [:critical, :high])
|
|
@@ -604,7 +604,8 @@ module RailsErrorDashboard
|
|
|
604
604
|
|
|
605
605
|
if enable_issue_tracking && effective_issue_tracker_provider.nil?
|
|
606
606
|
warnings << "enable_issue_tracking is true but provider could not be detected. " \
|
|
607
|
-
"Set issue_tracker_provider or git_repository_url."
|
|
607
|
+
"Set issue_tracker_provider (:github, :gitlab, :codeberg, :linear) or git_repository_url. " \
|
|
608
|
+
"Note: :linear is never auto-detected — set it explicitly with issue_tracker_repo as the team key."
|
|
608
609
|
end
|
|
609
610
|
end
|
|
610
611
|
|
|
@@ -736,9 +737,11 @@ module RailsErrorDashboard
|
|
|
736
737
|
default || blank
|
|
737
738
|
end
|
|
738
739
|
|
|
739
|
-
# Resolve the effective issue tracker provider (auto-detect from git_repository_url)
|
|
740
|
+
# Resolve the effective issue tracker provider (auto-detect from git_repository_url).
|
|
741
|
+
# Linear is never auto-detected (it is not a git forge) — set issue_tracker_provider
|
|
742
|
+
# explicitly.
|
|
740
743
|
#
|
|
741
|
-
# @return [Symbol, nil] :github, :gitlab, :codeberg, or nil
|
|
744
|
+
# @return [Symbol, nil] :github, :gitlab, :codeberg, :linear, or nil
|
|
742
745
|
def effective_issue_tracker_provider
|
|
743
746
|
return issue_tracker_provider&.to_sym if issue_tracker_provider.present?
|
|
744
747
|
return nil if git_repository_url.blank?
|
|
@@ -751,11 +754,13 @@ module RailsErrorDashboard
|
|
|
751
754
|
end
|
|
752
755
|
end
|
|
753
756
|
|
|
754
|
-
# Resolve the effective issue tracker repository ("owner/repo")
|
|
757
|
+
# Resolve the effective issue tracker repository ("owner/repo", or Linear team key)
|
|
755
758
|
#
|
|
756
|
-
# @return [String, nil] "owner/repo" or nil
|
|
759
|
+
# @return [String, nil] "owner/repo", Linear team key, or nil
|
|
757
760
|
def effective_issue_tracker_repo
|
|
758
761
|
return issue_tracker_repo if issue_tracker_repo.present?
|
|
762
|
+
# A git URL can never yield a Linear team key — require explicit config
|
|
763
|
+
return nil if effective_issue_tracker_provider == :linear
|
|
759
764
|
return nil if git_repository_url.blank?
|
|
760
765
|
|
|
761
766
|
# Extract owner/repo from URL: https://github.com/owner/repo(.git)
|
|
@@ -783,6 +788,7 @@ module RailsErrorDashboard
|
|
|
783
788
|
when :github then "https://api.github.com"
|
|
784
789
|
when :gitlab then "https://gitlab.com/api/v4"
|
|
785
790
|
when :codeberg then "https://codeberg.org/api/v1"
|
|
791
|
+
when :linear then "https://api.linear.app/graphql"
|
|
786
792
|
end
|
|
787
793
|
end
|
|
788
794
|
|
|
@@ -8,8 +8,9 @@ module RailsErrorDashboard
|
|
|
8
8
|
module Services
|
|
9
9
|
# Base class and factory for issue tracker API clients.
|
|
10
10
|
#
|
|
11
|
-
# Supports GitHub, GitLab,
|
|
12
|
-
# Each provider implements the same methods with provider-specific
|
|
11
|
+
# Supports GitHub, GitLab, Codeberg/Gitea/Forgejo, and Linear via a unified
|
|
12
|
+
# interface. Each provider implements the same methods with provider-specific
|
|
13
|
+
# API calls.
|
|
13
14
|
#
|
|
14
15
|
# @example
|
|
15
16
|
# client = IssueTrackerClient.for(:github, token: "ghp_xxx", repo: "user/repo")
|
|
@@ -23,9 +24,9 @@ module RailsErrorDashboard
|
|
|
23
24
|
|
|
24
25
|
# Factory method — returns the correct client for the provider
|
|
25
26
|
#
|
|
26
|
-
# @param provider [Symbol] :github, :gitlab, or :
|
|
27
|
+
# @param provider [Symbol] :github, :gitlab, :codeberg, or :linear
|
|
27
28
|
# @param token [String] API authentication token
|
|
28
|
-
# @param repo [String] Repository identifier ("owner/repo")
|
|
29
|
+
# @param repo [String] Repository identifier ("owner/repo"), or Linear team key ("ENG")
|
|
29
30
|
# @param api_url [String, nil] Custom API base URL (for self-hosted)
|
|
30
31
|
# @return [IssueTrackerClient] Provider-specific client instance
|
|
31
32
|
def self.for(provider, token:, repo:, api_url: nil)
|
|
@@ -36,8 +37,10 @@ module RailsErrorDashboard
|
|
|
36
37
|
GitLabIssueClient.new(token: token, repo: repo, api_url: api_url)
|
|
37
38
|
when :codeberg
|
|
38
39
|
CodebergIssueClient.new(token: token, repo: repo, api_url: api_url)
|
|
40
|
+
when :linear
|
|
41
|
+
LinearIssueClient.new(token: token, repo: repo, api_url: api_url)
|
|
39
42
|
else
|
|
40
|
-
raise ArgumentError, "Unknown issue tracker provider: #{provider}. Supported: :github, :gitlab, :codeberg"
|
|
43
|
+
raise ArgumentError, "Unknown issue tracker provider: #{provider}. Supported: :github, :gitlab, :codeberg, :linear"
|
|
41
44
|
end
|
|
42
45
|
end
|
|
43
46
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Linear GraphQL API client for issue management.
|
|
6
|
+
#
|
|
7
|
+
# API Docs: https://developers.linear.app/docs/graphql/working-with-the-graphql-api
|
|
8
|
+
# Auth: Personal API key (Settings > Security & access > Personal API keys)
|
|
9
|
+
# Rate limit: 1,500 requests/hour per API key
|
|
10
|
+
#
|
|
11
|
+
# Unlike the git forges, Linear has no "owner/repo" — issues belong to a
|
|
12
|
+
# team. The `repo` argument holds the team key (e.g. "ENG"), and issues
|
|
13
|
+
# are addressed by their human identifier ("ENG-123"), reconstructed from
|
|
14
|
+
# the team key plus the team-scoped issue number we store.
|
|
15
|
+
#
|
|
16
|
+
# Linear also has no open/closed binary — issues move between typed
|
|
17
|
+
# workflow states. Closing maps to the team's first `completed`-type
|
|
18
|
+
# state, reopening to the first `unstarted` (or `backlog`) state.
|
|
19
|
+
class LinearIssueClient < IssueTrackerClient
|
|
20
|
+
REOPEN_STATE_TYPES = [ "unstarted", "backlog", "triage" ].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(token:, repo:, api_url: nil)
|
|
23
|
+
super
|
|
24
|
+
@api_url = api_url || "https://api.linear.app/graphql"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_issue(title:, body:, labels: [])
|
|
28
|
+
return error_response(@last_error || "Linear team '#{@repo}' not found") unless team_id
|
|
29
|
+
|
|
30
|
+
input = { teamId: team_id, title: title, description: truncate_body(body) }
|
|
31
|
+
label_ids = resolve_label_ids(labels)
|
|
32
|
+
input[:labelIds] = label_ids if label_ids.any?
|
|
33
|
+
|
|
34
|
+
data = graphql(<<~GRAPHQL, input: input)
|
|
35
|
+
mutation($input: IssueCreateInput!) {
|
|
36
|
+
issueCreate(input: $input) { success issue { identifier number url } }
|
|
37
|
+
}
|
|
38
|
+
GRAPHQL
|
|
39
|
+
|
|
40
|
+
issue = data&.dig("issueCreate", "issue")
|
|
41
|
+
if issue
|
|
42
|
+
success_response(url: issue["url"], number: issue["number"])
|
|
43
|
+
else
|
|
44
|
+
error_response(@last_error || "Linear API error: issue creation failed")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close_issue(number:)
|
|
49
|
+
update_issue_state(number, "completed")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reopen_issue(number:)
|
|
53
|
+
update_issue_state(number, REOPEN_STATE_TYPES)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_comment(number:, body:)
|
|
57
|
+
issue_id = find_issue_id(number)
|
|
58
|
+
return error_response(@last_error || "Linear issue #{identifier_for(number)} not found") unless issue_id
|
|
59
|
+
|
|
60
|
+
data = graphql(<<~GRAPHQL, input: { issueId: issue_id, body: truncate_body(body) })
|
|
61
|
+
mutation($input: CommentCreateInput!) {
|
|
62
|
+
commentCreate(input: $input) { success comment { url } }
|
|
63
|
+
}
|
|
64
|
+
GRAPHQL
|
|
65
|
+
|
|
66
|
+
comment = data&.dig("commentCreate", "comment")
|
|
67
|
+
comment ? success_response(url: comment["url"]) : error_response(@last_error || "Linear API error: comment failed")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fetch_comments(number:, per_page: 10)
|
|
71
|
+
data = graphql(<<~GRAPHQL, id: identifier_for(number), first: per_page)
|
|
72
|
+
query($id: String!, $first: Int!) {
|
|
73
|
+
issue(id: $id) {
|
|
74
|
+
comments(first: $first) {
|
|
75
|
+
nodes { body createdAt url user { name avatarUrl } }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
GRAPHQL
|
|
80
|
+
|
|
81
|
+
nodes = data&.dig("issue", "comments", "nodes")
|
|
82
|
+
return error_response(@last_error || "Linear API error: could not fetch comments") unless nodes
|
|
83
|
+
|
|
84
|
+
comments = nodes.map { |c|
|
|
85
|
+
{
|
|
86
|
+
author: c.dig("user", "name"),
|
|
87
|
+
avatar_url: c.dig("user", "avatarUrl"),
|
|
88
|
+
body: c["body"],
|
|
89
|
+
created_at: c["createdAt"],
|
|
90
|
+
url: c["url"]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
success_response(comments: comments)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fetch_issue(number:)
|
|
97
|
+
data = graphql(<<~GRAPHQL, id: identifier_for(number))
|
|
98
|
+
query($id: String!) {
|
|
99
|
+
issue(id: $id) {
|
|
100
|
+
title
|
|
101
|
+
state { name type }
|
|
102
|
+
assignee { name avatarUrl }
|
|
103
|
+
labels { nodes { name color } }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
GRAPHQL
|
|
107
|
+
|
|
108
|
+
issue = data&.dig("issue")
|
|
109
|
+
return error_response(@last_error || "Linear API error: could not fetch issue") unless issue
|
|
110
|
+
|
|
111
|
+
assignee = issue["assignee"]
|
|
112
|
+
success_response(
|
|
113
|
+
state: closed_state_type?(issue.dig("state", "type")) ? "closed" : "open",
|
|
114
|
+
title: issue["title"],
|
|
115
|
+
assignees: assignee ? [ { login: assignee["name"], avatar_url: assignee["avatarUrl"] } ] : [],
|
|
116
|
+
labels: (issue.dig("labels", "nodes") || []).map { |l|
|
|
117
|
+
{ name: l["name"], color: l["color"]&.delete("#") }
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Linear issues are addressed by "TEAM-123" — team key + stored number
|
|
125
|
+
def identifier_for(number)
|
|
126
|
+
"#{@repo}-#{number}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def closed_state_type?(state_type)
|
|
130
|
+
[ "completed", "canceled" ].include?(state_type)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def update_issue_state(number, state_types)
|
|
134
|
+
issue_id = find_issue_id(number)
|
|
135
|
+
return error_response(@last_error || "Linear issue #{identifier_for(number)} not found") unless issue_id
|
|
136
|
+
|
|
137
|
+
state_id = workflow_state_id(Array(state_types))
|
|
138
|
+
return error_response(@last_error || "No matching workflow state for #{Array(state_types).join('/')}") unless state_id
|
|
139
|
+
|
|
140
|
+
data = graphql(<<~GRAPHQL, id: issue_id, input: { stateId: state_id })
|
|
141
|
+
mutation($id: String!, $input: IssueUpdateInput!) {
|
|
142
|
+
issueUpdate(id: $id, input: $input) { success }
|
|
143
|
+
}
|
|
144
|
+
GRAPHQL
|
|
145
|
+
|
|
146
|
+
data&.dig("issueUpdate", "success") ? success_response({}) : error_response(@last_error || "Linear API error: state update failed")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def find_issue_id(number)
|
|
150
|
+
data = graphql("query($id: String!) { issue(id: $id) { id } }", id: identifier_for(number))
|
|
151
|
+
data&.dig("issue", "id")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def team_id
|
|
155
|
+
@team_id ||= begin
|
|
156
|
+
data = graphql(<<~GRAPHQL, key: @repo)
|
|
157
|
+
query($key: String!) {
|
|
158
|
+
teams(filter: { key: { eq: $key } }) { nodes { id } }
|
|
159
|
+
}
|
|
160
|
+
GRAPHQL
|
|
161
|
+
data&.dig("teams", "nodes", 0, "id")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Pick the first workflow state whose type matches, in preference order
|
|
166
|
+
def workflow_state_id(preferred_types)
|
|
167
|
+
states = workflow_states
|
|
168
|
+
return nil unless states
|
|
169
|
+
|
|
170
|
+
preferred_types.each do |type|
|
|
171
|
+
match = states.find { |s| s["type"] == type }
|
|
172
|
+
return match["id"] if match
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def workflow_states
|
|
178
|
+
@workflow_states ||= begin
|
|
179
|
+
data = graphql(<<~GRAPHQL, key: @repo)
|
|
180
|
+
query($key: String!) {
|
|
181
|
+
workflowStates(filter: { team: { key: { eq: $key } } }) {
|
|
182
|
+
nodes { id name type position }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
GRAPHQL
|
|
186
|
+
data&.dig("workflowStates", "nodes")&.sort_by { |s| s["position"].to_f }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Best-effort: resolve label names to UUIDs, creating missing ones.
|
|
191
|
+
# Label failures must never block issue creation.
|
|
192
|
+
def resolve_label_ids(names)
|
|
193
|
+
names = Array(names).map(&:to_s).reject(&:empty?)
|
|
194
|
+
return [] if names.empty?
|
|
195
|
+
|
|
196
|
+
data = graphql(<<~GRAPHQL, names: names)
|
|
197
|
+
query($names: [String!]) {
|
|
198
|
+
issueLabels(filter: { name: { in: $names } }) { nodes { id name } }
|
|
199
|
+
}
|
|
200
|
+
GRAPHQL
|
|
201
|
+
existing = data&.dig("issueLabels", "nodes") || []
|
|
202
|
+
ids = existing.map { |l| l["id"] }
|
|
203
|
+
|
|
204
|
+
missing = names - existing.map { |l| l["name"] }
|
|
205
|
+
missing.each do |name|
|
|
206
|
+
created = graphql(<<~GRAPHQL, input: { name: name, teamId: team_id })
|
|
207
|
+
mutation($input: IssueLabelCreateInput!) {
|
|
208
|
+
issueLabelCreate(input: $input) { issueLabel { id } }
|
|
209
|
+
}
|
|
210
|
+
GRAPHQL
|
|
211
|
+
id = created&.dig("issueLabelCreate", "issueLabel", "id")
|
|
212
|
+
ids << id if id
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
ids
|
|
216
|
+
rescue
|
|
217
|
+
[]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Execute a GraphQL request. Returns the "data" hash, or nil on any
|
|
221
|
+
# error (with the message stashed in @last_error for the caller).
|
|
222
|
+
def graphql(query, variables = {})
|
|
223
|
+
@last_error = nil
|
|
224
|
+
response = http_post(@api_url, { query: query, variables: variables }, auth_headers)
|
|
225
|
+
|
|
226
|
+
if response[:status] != 200
|
|
227
|
+
message = response.dig(:body, "errors", 0, "message") || response[:error]
|
|
228
|
+
@last_error = "Linear API error (#{response[:status]}): #{message}"
|
|
229
|
+
return nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
errors = response.dig(:body, "errors")
|
|
233
|
+
if errors.present?
|
|
234
|
+
@last_error = "Linear API error: #{errors.first["message"]}"
|
|
235
|
+
return nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
response.dig(:body, "data")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def auth_headers
|
|
242
|
+
# Personal API keys are passed bare; OAuth tokens need a Bearer prefix
|
|
243
|
+
value = @token.to_s.start_with?("lin_api_") ? @token : "Bearer #{@token}"
|
|
244
|
+
{ "Authorization" => value }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -68,6 +68,7 @@ require "rails_error_dashboard/services/issue_tracker_client"
|
|
|
68
68
|
require "rails_error_dashboard/services/github_issue_client"
|
|
69
69
|
require "rails_error_dashboard/services/gitlab_issue_client"
|
|
70
70
|
require "rails_error_dashboard/services/codeberg_issue_client"
|
|
71
|
+
require "rails_error_dashboard/services/linear_issue_client"
|
|
71
72
|
require "rails_error_dashboard/services/database_health_inspector"
|
|
72
73
|
require "rails_error_dashboard/services/cache_analyzer"
|
|
73
74
|
require "rails_error_dashboard/services/llm_summary"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_error_dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -463,6 +463,7 @@ files:
|
|
|
463
463
|
- lib/rails_error_dashboard/services/gitlab_issue_client.rb
|
|
464
464
|
- lib/rails_error_dashboard/services/issue_body_formatter.rb
|
|
465
465
|
- lib/rails_error_dashboard/services/issue_tracker_client.rb
|
|
466
|
+
- lib/rails_error_dashboard/services/linear_issue_client.rb
|
|
466
467
|
- lib/rails_error_dashboard/services/llm_client.rb
|
|
467
468
|
- lib/rails_error_dashboard/services/llm_cost_estimator.rb
|
|
468
469
|
- lib/rails_error_dashboard/services/llm_summary.rb
|
|
@@ -511,7 +512,7 @@ metadata:
|
|
|
511
512
|
funding_uri: https://github.com/sponsors/AnjanJ
|
|
512
513
|
post_install_message: |
|
|
513
514
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
514
|
-
RED (Rails Error Dashboard) v0.8.
|
|
515
|
+
RED (Rails Error Dashboard) v0.8.1
|
|
515
516
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
516
517
|
|
|
517
518
|
First install:
|
|
@@ -547,7 +548,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
547
548
|
- !ruby/object:Gem::Version
|
|
548
549
|
version: '0'
|
|
549
550
|
requirements: []
|
|
550
|
-
rubygems_version:
|
|
551
|
+
rubygems_version: 3.6.9
|
|
551
552
|
specification_version: 4
|
|
552
553
|
summary: Self-hosted error tracking and exception monitoring for Rails. Free, forever.
|
|
553
554
|
test_files: []
|