linear_api 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE.txt +21 -0
- data/README.md +371 -0
- data/app/models/linear_api/application_record.rb +8 -0
- data/app/models/linear_api/cached_metadata.rb +102 -0
- data/app/models/linear_api/synced_issue.rb +76 -0
- data/db/migrate/20260204000001_create_linear_api_cached_metadata.rb +22 -0
- data/db/migrate/20260204000002_create_linear_api_synced_issues.rb +26 -0
- data/lib/linear_api/cache_sync.rb +185 -0
- data/lib/linear_api/client.rb +519 -0
- data/lib/linear_api/engine.rb +27 -0
- data/lib/linear_api/issue.rb +169 -0
- data/lib/linear_api/issue_tracker.rb +291 -0
- data/lib/linear_api/label.rb +40 -0
- data/lib/linear_api/project.rb +43 -0
- data/lib/linear_api/result.rb +36 -0
- data/lib/linear_api/team.rb +51 -0
- data/lib/linear_api/version.rb +5 -0
- data/lib/linear_api.rb +41 -0
- data/lib/tasks/linear_api.rake +164 -0
- metadata +268 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearApi
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace LinearApi
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :rspec
|
|
9
|
+
g.fixture_replacement :factory_bot
|
|
10
|
+
g.factory_bot dir: 'spec/factories'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Load rake tasks
|
|
14
|
+
rake_tasks do
|
|
15
|
+
load File.expand_path('../tasks/linear_api.rake', __dir__)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer 'linear_api.configure' do
|
|
19
|
+
# Auto-configure from environment variables if not already set
|
|
20
|
+
LinearApi.api_key ||= ENV['LINEAR_API_KEY']
|
|
21
|
+
LinearApi.team_id ||= ENV['LINEAR_TEAM_ID']
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Eager load models in production
|
|
25
|
+
config.eager_load_namespaces << LinearApi
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearApi
|
|
4
|
+
# Represents a Linear issue
|
|
5
|
+
class Issue
|
|
6
|
+
CREATE_MUTATION = <<~GRAPHQL
|
|
7
|
+
mutation CreateIssue($input: IssueCreateInput!) {
|
|
8
|
+
issueCreate(input: $input) {
|
|
9
|
+
success
|
|
10
|
+
issue {
|
|
11
|
+
id
|
|
12
|
+
identifier
|
|
13
|
+
title
|
|
14
|
+
url
|
|
15
|
+
state {
|
|
16
|
+
id
|
|
17
|
+
name
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
GRAPHQL
|
|
23
|
+
|
|
24
|
+
UPDATE_MUTATION = <<~GRAPHQL
|
|
25
|
+
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
|
|
26
|
+
issueUpdate(id: $id, input: $input) {
|
|
27
|
+
success
|
|
28
|
+
issue {
|
|
29
|
+
id
|
|
30
|
+
identifier
|
|
31
|
+
title
|
|
32
|
+
state {
|
|
33
|
+
id
|
|
34
|
+
name
|
|
35
|
+
}
|
|
36
|
+
labels {
|
|
37
|
+
nodes {
|
|
38
|
+
id
|
|
39
|
+
name
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
GRAPHQL
|
|
46
|
+
|
|
47
|
+
SEARCH_QUERY = <<~GRAPHQL
|
|
48
|
+
query GetIssue($term: String!) {
|
|
49
|
+
searchIssues(term: $term, first: 1) {
|
|
50
|
+
nodes {
|
|
51
|
+
id
|
|
52
|
+
identifier
|
|
53
|
+
title
|
|
54
|
+
description
|
|
55
|
+
url
|
|
56
|
+
priority
|
|
57
|
+
state {
|
|
58
|
+
id
|
|
59
|
+
name
|
|
60
|
+
type
|
|
61
|
+
}
|
|
62
|
+
labels {
|
|
63
|
+
nodes {
|
|
64
|
+
id
|
|
65
|
+
name
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
assignee {
|
|
69
|
+
id
|
|
70
|
+
name
|
|
71
|
+
}
|
|
72
|
+
project {
|
|
73
|
+
id
|
|
74
|
+
name
|
|
75
|
+
}
|
|
76
|
+
comments {
|
|
77
|
+
nodes {
|
|
78
|
+
id
|
|
79
|
+
body
|
|
80
|
+
createdAt
|
|
81
|
+
user {
|
|
82
|
+
name
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
GRAPHQL
|
|
90
|
+
|
|
91
|
+
LIST_QUERY = <<~GRAPHQL
|
|
92
|
+
query ListIssues($teamId: String!, $first: Int!) {
|
|
93
|
+
team(id: $teamId) {
|
|
94
|
+
issues(first: $first, orderBy: updatedAt) {
|
|
95
|
+
nodes {
|
|
96
|
+
id
|
|
97
|
+
identifier
|
|
98
|
+
title
|
|
99
|
+
priority
|
|
100
|
+
state {
|
|
101
|
+
id
|
|
102
|
+
name
|
|
103
|
+
type
|
|
104
|
+
}
|
|
105
|
+
labels {
|
|
106
|
+
nodes {
|
|
107
|
+
id
|
|
108
|
+
name
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
GRAPHQL
|
|
116
|
+
|
|
117
|
+
ADD_COMMENT_MUTATION = <<~GRAPHQL
|
|
118
|
+
mutation AddComment($issueId: String!, $body: String!) {
|
|
119
|
+
commentCreate(input: { issueId: $issueId, body: $body }) {
|
|
120
|
+
success
|
|
121
|
+
comment {
|
|
122
|
+
id
|
|
123
|
+
body
|
|
124
|
+
createdAt
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
GRAPHQL
|
|
129
|
+
|
|
130
|
+
attr_reader :id, :identifier, :title, :description, :url, :priority,
|
|
131
|
+
:state, :labels, :assignee, :project, :comments, :raw
|
|
132
|
+
|
|
133
|
+
def initialize(data)
|
|
134
|
+
@raw = data
|
|
135
|
+
@id = data['id']
|
|
136
|
+
@identifier = data['identifier']
|
|
137
|
+
@title = data['title']
|
|
138
|
+
@description = data['description']
|
|
139
|
+
@url = data['url']
|
|
140
|
+
@priority = data['priority']
|
|
141
|
+
@state = data['state']
|
|
142
|
+
@labels = (data.dig('labels', 'nodes') || [])
|
|
143
|
+
@assignee = data['assignee']
|
|
144
|
+
@project = data['project']
|
|
145
|
+
@comments = (data.dig('comments', 'nodes') || [])
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def state_name
|
|
149
|
+
state&.dig('name')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def label_names
|
|
153
|
+
labels.map { |l| l['name'] }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def to_h
|
|
157
|
+
{
|
|
158
|
+
id: id,
|
|
159
|
+
identifier: identifier,
|
|
160
|
+
title: title,
|
|
161
|
+
description: description,
|
|
162
|
+
url: url,
|
|
163
|
+
priority: priority,
|
|
164
|
+
state: state_name,
|
|
165
|
+
labels: label_names
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearApi
|
|
4
|
+
class IssueTracker
|
|
5
|
+
DEFAULT_LABELS = %i[bug sync].freeze
|
|
6
|
+
AUTO_TRACKED_PROJECT_NAME = 'Auto-Tracked Errors'
|
|
7
|
+
AUTO_TRACKED_PROJECT_KEY = 'auto_tracked_errors'
|
|
8
|
+
AUTO_TRACKED_LABEL_NAME = 'auto:tracked'
|
|
9
|
+
AUTO_TRACKED_LABEL_COLOR = '#6b7280' # Gray
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Track an error and create/update a Linear issue
|
|
13
|
+
#
|
|
14
|
+
# @param exception [Exception] The exception to track
|
|
15
|
+
# @param context [Hash] Additional context (date, IDs, etc.)
|
|
16
|
+
# @param source [Object] The source object (AccountLinking, etc.)
|
|
17
|
+
# @param labels [Array<Symbol, String>] Labels to apply (:bug, :sync, :critical, or IDs)
|
|
18
|
+
# @return [LinearApi::Result, LinearApi::SyncedIssue] Result or existing issue
|
|
19
|
+
def track_error(exception:, context: {}, source: nil, labels: DEFAULT_LABELS)
|
|
20
|
+
fingerprint = generate_fingerprint(exception, source)
|
|
21
|
+
|
|
22
|
+
# Check for existing open issue
|
|
23
|
+
existing = SyncedIssue.find_open_for_fingerprint(fingerprint)
|
|
24
|
+
return add_occurrence_comment(existing, exception, context) if existing
|
|
25
|
+
|
|
26
|
+
# Ensure auto-tracked project and label exist
|
|
27
|
+
project_id = ensure_auto_tracked_project
|
|
28
|
+
auto_label_id = ensure_auto_tracked_label
|
|
29
|
+
|
|
30
|
+
# Resolve label IDs from cache + add auto:tracked label
|
|
31
|
+
label_ids = resolve_label_ids(labels)
|
|
32
|
+
label_ids << auto_label_id if auto_label_id
|
|
33
|
+
|
|
34
|
+
# Create new Linear issue
|
|
35
|
+
result = LinearApi.client.create_issue(
|
|
36
|
+
title: build_title(exception),
|
|
37
|
+
description: build_description(exception, context, source),
|
|
38
|
+
priority: determine_priority(exception, context),
|
|
39
|
+
label_ids: label_ids.compact.uniq,
|
|
40
|
+
project_id: project_id
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return result unless result.success?
|
|
44
|
+
|
|
45
|
+
# Track locally
|
|
46
|
+
synced_issue = SyncedIssue.create!(
|
|
47
|
+
linear_id: result.data.id,
|
|
48
|
+
identifier: result.data.identifier,
|
|
49
|
+
title: result.data.title,
|
|
50
|
+
state_name: 'Backlog',
|
|
51
|
+
priority: determine_priority(exception, context),
|
|
52
|
+
fingerprint: fingerprint,
|
|
53
|
+
source_type: source&.class&.name,
|
|
54
|
+
source_id: source&.id&.to_s,
|
|
55
|
+
metadata: context.to_json,
|
|
56
|
+
synced_at: Time.current
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
Result.new(success: true, data: synced_issue)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Track a custom issue (not from exception)
|
|
63
|
+
def track_issue(title:, description:, context: {}, source: nil, labels: [], priority: 3)
|
|
64
|
+
fingerprint = generate_custom_fingerprint(title, source)
|
|
65
|
+
|
|
66
|
+
existing = SyncedIssue.find_open_for_fingerprint(fingerprint)
|
|
67
|
+
return Result.new(success: true, data: existing) if existing
|
|
68
|
+
|
|
69
|
+
# Ensure auto-tracked project and label exist
|
|
70
|
+
project_id = ensure_auto_tracked_project
|
|
71
|
+
auto_label_id = ensure_auto_tracked_label
|
|
72
|
+
|
|
73
|
+
# Resolve label IDs + add auto:tracked label
|
|
74
|
+
label_ids = resolve_label_ids(labels)
|
|
75
|
+
label_ids << auto_label_id if auto_label_id
|
|
76
|
+
|
|
77
|
+
result = LinearApi.client.create_issue(
|
|
78
|
+
title: "[Auto] #{title}",
|
|
79
|
+
description: description,
|
|
80
|
+
priority: priority,
|
|
81
|
+
label_ids: label_ids.compact.uniq,
|
|
82
|
+
project_id: project_id
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return result unless result.success?
|
|
86
|
+
|
|
87
|
+
synced_issue = SyncedIssue.create!(
|
|
88
|
+
linear_id: result.data.id,
|
|
89
|
+
identifier: result.data.identifier,
|
|
90
|
+
title: result.data.title,
|
|
91
|
+
state_name: 'Backlog',
|
|
92
|
+
priority: priority,
|
|
93
|
+
fingerprint: fingerprint,
|
|
94
|
+
source_type: source&.class&.name,
|
|
95
|
+
source_id: source&.id&.to_s,
|
|
96
|
+
metadata: context.to_json,
|
|
97
|
+
synced_at: Time.current
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
Result.new(success: true, data: synced_issue)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Setup auto-tracking infrastructure (project + label)
|
|
104
|
+
def setup!
|
|
105
|
+
{
|
|
106
|
+
project_id: ensure_auto_tracked_project,
|
|
107
|
+
label_id: ensure_auto_tracked_label
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def generate_fingerprint(exception, source = nil)
|
|
114
|
+
location = exception.backtrace&.first || 'unknown'
|
|
115
|
+
# Strip Rails root if available
|
|
116
|
+
location = location.gsub(Rails.root.to_s, '') if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
117
|
+
base = "#{exception.class}:#{location}"
|
|
118
|
+
source ? "#{base}:#{source.class}:#{source.id}" : base
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def generate_custom_fingerprint(title, source = nil)
|
|
122
|
+
base = Digest::SHA256.hexdigest(title)[0..16]
|
|
123
|
+
source ? "custom:#{base}:#{source.class}:#{source.id}" : "custom:#{base}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def resolve_label_ids(labels)
|
|
127
|
+
labels.filter_map { |label| CachedMetadata.resolve_label_id(label) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Ensure the "Auto-Tracked Errors" project exists
|
|
131
|
+
def ensure_auto_tracked_project
|
|
132
|
+
# Check cache first
|
|
133
|
+
cached = CachedMetadata.projects.find_by(key: AUTO_TRACKED_PROJECT_KEY)
|
|
134
|
+
return cached.linear_id if cached
|
|
135
|
+
|
|
136
|
+
# Check if project exists in Linear
|
|
137
|
+
result = LinearApi.client.list_projects
|
|
138
|
+
if result.success?
|
|
139
|
+
existing = result.data.find { |p| p.name == AUTO_TRACKED_PROJECT_NAME }
|
|
140
|
+
if existing
|
|
141
|
+
cache_project(existing)
|
|
142
|
+
return existing.id
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Create new project
|
|
147
|
+
create_result = LinearApi.client.create_project(
|
|
148
|
+
name: AUTO_TRACKED_PROJECT_NAME,
|
|
149
|
+
description: 'Automatically tracked errors and issues from the application. Do not manually add issues here.',
|
|
150
|
+
color: '#6b7280'
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return nil unless create_result.success?
|
|
154
|
+
|
|
155
|
+
cache_project(create_result.data)
|
|
156
|
+
create_result.data.id
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Ensure the "auto:tracked" label exists
|
|
160
|
+
def ensure_auto_tracked_label
|
|
161
|
+
# Check cache first
|
|
162
|
+
cached = CachedMetadata.labels.find_by(key: AUTO_TRACKED_LABEL_NAME)
|
|
163
|
+
return cached.linear_id if cached
|
|
164
|
+
|
|
165
|
+
# Check if label exists in Linear
|
|
166
|
+
result = LinearApi.client.list_labels
|
|
167
|
+
if result.success?
|
|
168
|
+
existing = result.data.find { |l| l.name == AUTO_TRACKED_LABEL_NAME }
|
|
169
|
+
if existing
|
|
170
|
+
cache_label(existing)
|
|
171
|
+
return existing.id
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Create new label
|
|
176
|
+
create_result = LinearApi.client.create_label(
|
|
177
|
+
name: AUTO_TRACKED_LABEL_NAME,
|
|
178
|
+
color: AUTO_TRACKED_LABEL_COLOR,
|
|
179
|
+
description: 'Automatically tracked by LinearApi::IssueTracker'
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return nil unless create_result.success?
|
|
183
|
+
|
|
184
|
+
cache_label(create_result.data)
|
|
185
|
+
create_result.data.id
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def cache_project(project)
|
|
189
|
+
CachedMetadata.upsert_from_api(
|
|
190
|
+
type: 'project',
|
|
191
|
+
linear_id: project.id,
|
|
192
|
+
name: project.name,
|
|
193
|
+
key: AUTO_TRACKED_PROJECT_KEY,
|
|
194
|
+
raw_data: project.to_h
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def cache_label(label)
|
|
199
|
+
CachedMetadata.upsert_from_api(
|
|
200
|
+
type: 'label',
|
|
201
|
+
linear_id: label.id,
|
|
202
|
+
name: label.name,
|
|
203
|
+
key: AUTO_TRACKED_LABEL_NAME,
|
|
204
|
+
category: 'auto',
|
|
205
|
+
color: label.color,
|
|
206
|
+
raw_data: label.to_h
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def determine_priority(exception, context)
|
|
211
|
+
# Priority: 1=Urgent, 2=High, 3=Medium, 4=Low
|
|
212
|
+
return 1 if context[:critical] || context[:urgent]
|
|
213
|
+
return 2 if exception.is_a?(ActiveRecord::StatementInvalid)
|
|
214
|
+
return 2 if context[:high_priority]
|
|
215
|
+
|
|
216
|
+
3 # Default to medium
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def build_title(exception)
|
|
220
|
+
message = exception.message.to_s.truncate(80)
|
|
221
|
+
"[Auto] #{exception.class.name.demodulize}: #{message}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def build_description(exception, context, source)
|
|
225
|
+
<<~MARKDOWN
|
|
226
|
+
## Error Details
|
|
227
|
+
|
|
228
|
+
**Exception:** `#{exception.class}`
|
|
229
|
+
**Message:** #{exception.message}
|
|
230
|
+
**Source:** #{source_description(source)}
|
|
231
|
+
|
|
232
|
+
## Context
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
#{JSON.pretty_generate(sanitize_context(context))}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Backtrace (first 20 lines)
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
#{exception.backtrace&.first(20)&.join("\n")}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
*Auto-created by LinearApi::IssueTracker at #{Time.current.iso8601}*
|
|
246
|
+
MARKDOWN
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def source_description(source)
|
|
250
|
+
return 'N/A' unless source
|
|
251
|
+
|
|
252
|
+
"#{source.class.name} ##{source.id}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def sanitize_context(context)
|
|
256
|
+
context.transform_values do |value|
|
|
257
|
+
case value
|
|
258
|
+
when Time, DateTime then value.iso8601
|
|
259
|
+
when Date then value.to_s
|
|
260
|
+
when ActiveRecord::Base then "#{value.class.name}##{value.id}"
|
|
261
|
+
else value
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def add_occurrence_comment(synced_issue, exception, context)
|
|
267
|
+
comment_body = <<~MARKDOWN
|
|
268
|
+
## New Occurrence
|
|
269
|
+
|
|
270
|
+
**Time:** #{Time.current.iso8601}
|
|
271
|
+
**Message:** #{exception.message}
|
|
272
|
+
|
|
273
|
+
### Context
|
|
274
|
+
|
|
275
|
+
```json
|
|
276
|
+
#{JSON.pretty_generate(sanitize_context(context))}
|
|
277
|
+
```
|
|
278
|
+
MARKDOWN
|
|
279
|
+
|
|
280
|
+
LinearApi.client.add_comment(
|
|
281
|
+
issue_id: synced_issue.linear_id,
|
|
282
|
+
body: comment_body
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
synced_issue.update!(synced_at: Time.current)
|
|
286
|
+
|
|
287
|
+
Result.new(success: true, data: synced_issue)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearApi
|
|
4
|
+
# Represents a Linear label
|
|
5
|
+
class Label
|
|
6
|
+
LIST_QUERY = <<~GRAPHQL
|
|
7
|
+
query ListLabels($teamId: String!) {
|
|
8
|
+
team(id: $teamId) {
|
|
9
|
+
labels {
|
|
10
|
+
nodes {
|
|
11
|
+
id
|
|
12
|
+
name
|
|
13
|
+
color
|
|
14
|
+
description
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
GRAPHQL
|
|
20
|
+
|
|
21
|
+
attr_reader :id, :name, :color, :description, :raw
|
|
22
|
+
|
|
23
|
+
def initialize(data)
|
|
24
|
+
@raw = data
|
|
25
|
+
@id = data['id']
|
|
26
|
+
@name = data['name']
|
|
27
|
+
@color = data['color']
|
|
28
|
+
@description = data['description']
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
id: id,
|
|
34
|
+
name: name,
|
|
35
|
+
color: color,
|
|
36
|
+
description: description
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearApi
|
|
4
|
+
# Represents a Linear project (epic)
|
|
5
|
+
class Project
|
|
6
|
+
LIST_QUERY = <<~GRAPHQL
|
|
7
|
+
query ListProjects($teamId: String!) {
|
|
8
|
+
team(id: $teamId) {
|
|
9
|
+
projects {
|
|
10
|
+
nodes {
|
|
11
|
+
id
|
|
12
|
+
name
|
|
13
|
+
description
|
|
14
|
+
state
|
|
15
|
+
progress
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
GRAPHQL
|
|
21
|
+
|
|
22
|
+
attr_reader :id, :name, :description, :state, :progress, :raw
|
|
23
|
+
|
|
24
|
+
def initialize(data)
|
|
25
|
+
@raw = data
|
|
26
|
+
@id = data['id']
|
|
27
|
+
@name = data['name']
|
|
28
|
+
@description = data['description']
|
|
29
|
+
@state = data['state']
|
|
30
|
+
@progress = data['progress']
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
id: id,
|
|
36
|
+
name: name,
|
|
37
|
+
description: description,
|
|
38
|
+
state: state,
|
|
39
|
+
progress: progress
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearApi
|
|
4
|
+
# Result object for API operations
|
|
5
|
+
class Result
|
|
6
|
+
attr_reader :data, :error, :raw_response
|
|
7
|
+
|
|
8
|
+
def initialize(success:, data: nil, error: nil, raw_response: nil)
|
|
9
|
+
@success = success
|
|
10
|
+
@data = data
|
|
11
|
+
@error = error
|
|
12
|
+
@raw_response = raw_response
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def success?
|
|
16
|
+
@success
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def failure?
|
|
20
|
+
!@success
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Allow accessing data directly on result
|
|
24
|
+
def method_missing(method, *args, &block)
|
|
25
|
+
if data.respond_to?(method)
|
|
26
|
+
data.public_send(method, *args, &block)
|
|
27
|
+
else
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def respond_to_missing?(method, include_private = false)
|
|
33
|
+
data.respond_to?(method, include_private) || super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearApi
|
|
4
|
+
# Represents a Linear team
|
|
5
|
+
class Team
|
|
6
|
+
GET_QUERY = <<~GRAPHQL
|
|
7
|
+
query GetTeam($teamId: String!) {
|
|
8
|
+
team(id: $teamId) {
|
|
9
|
+
id
|
|
10
|
+
name
|
|
11
|
+
key
|
|
12
|
+
description
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
GRAPHQL
|
|
16
|
+
|
|
17
|
+
STATES_QUERY = <<~GRAPHQL
|
|
18
|
+
query GetStates($teamId: String!) {
|
|
19
|
+
team(id: $teamId) {
|
|
20
|
+
states {
|
|
21
|
+
nodes {
|
|
22
|
+
id
|
|
23
|
+
name
|
|
24
|
+
type
|
|
25
|
+
position
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
GRAPHQL
|
|
31
|
+
|
|
32
|
+
attr_reader :id, :name, :key, :description, :raw
|
|
33
|
+
|
|
34
|
+
def initialize(data)
|
|
35
|
+
@raw = data
|
|
36
|
+
@id = data['id']
|
|
37
|
+
@name = data['name']
|
|
38
|
+
@key = data['key']
|
|
39
|
+
@description = data['description']
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
{
|
|
44
|
+
id: id,
|
|
45
|
+
name: name,
|
|
46
|
+
key: key,
|
|
47
|
+
description: description
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/linear_api.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'linear_api/version'
|
|
4
|
+
require_relative 'linear_api/client'
|
|
5
|
+
require_relative 'linear_api/result'
|
|
6
|
+
require_relative 'linear_api/issue'
|
|
7
|
+
require_relative 'linear_api/project'
|
|
8
|
+
require_relative 'linear_api/label'
|
|
9
|
+
require_relative 'linear_api/team'
|
|
10
|
+
|
|
11
|
+
# Load Rails engine and services if Rails is present
|
|
12
|
+
if defined?(Rails::Engine)
|
|
13
|
+
require_relative 'linear_api/engine'
|
|
14
|
+
require_relative 'linear_api/cache_sync'
|
|
15
|
+
require_relative 'linear_api/issue_tracker'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module LinearApi
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
class AuthenticationError < Error; end
|
|
21
|
+
class NotFoundError < Error; end
|
|
22
|
+
class RateLimitError < Error; end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
attr_accessor :api_key, :team_id
|
|
26
|
+
|
|
27
|
+
def configure
|
|
28
|
+
yield self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def client
|
|
32
|
+
raise AuthenticationError, 'API key not configured' unless api_key
|
|
33
|
+
|
|
34
|
+
@client ||= Client.new(api_key: api_key, team_id: team_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reset_client!
|
|
38
|
+
@client = nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|