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,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LinearApi
|
|
4
|
+
class CacheSync
|
|
5
|
+
class << self
|
|
6
|
+
def sync_all
|
|
7
|
+
new.sync_all
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def import_from_json(json_path)
|
|
11
|
+
new.import_from_json(json_path)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def sync_all
|
|
16
|
+
{
|
|
17
|
+
labels: sync_labels,
|
|
18
|
+
projects: sync_projects,
|
|
19
|
+
states: sync_states,
|
|
20
|
+
users: sync_users
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def sync_labels
|
|
25
|
+
result = LinearApi.client.list_labels
|
|
26
|
+
return { success: false, error: result.error } unless result.success?
|
|
27
|
+
|
|
28
|
+
count = 0
|
|
29
|
+
result.data.each do |label|
|
|
30
|
+
category = extract_category(label.name)
|
|
31
|
+
CachedMetadata.upsert_from_api(
|
|
32
|
+
type: 'label',
|
|
33
|
+
linear_id: label.id,
|
|
34
|
+
name: label.name,
|
|
35
|
+
key: label.name,
|
|
36
|
+
color: label.color,
|
|
37
|
+
category: category,
|
|
38
|
+
raw_data: label.to_h
|
|
39
|
+
)
|
|
40
|
+
count += 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
{ success: true, count: count }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def sync_projects
|
|
47
|
+
result = LinearApi.client.list_projects
|
|
48
|
+
return { success: false, error: result.error } unless result.success?
|
|
49
|
+
|
|
50
|
+
count = 0
|
|
51
|
+
result.data.each do |project|
|
|
52
|
+
key = project.name.parameterize.underscore
|
|
53
|
+
CachedMetadata.upsert_from_api(
|
|
54
|
+
type: 'project',
|
|
55
|
+
linear_id: project.id,
|
|
56
|
+
name: project.name,
|
|
57
|
+
key: key,
|
|
58
|
+
state_type: project.state,
|
|
59
|
+
raw_data: project.to_h
|
|
60
|
+
)
|
|
61
|
+
count += 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
{ success: true, count: count }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sync_states
|
|
68
|
+
result = LinearApi.client.list_states
|
|
69
|
+
return { success: false, error: result.error } unless result.success?
|
|
70
|
+
|
|
71
|
+
count = 0
|
|
72
|
+
result.data.each do |state|
|
|
73
|
+
key = state['name'].parameterize.underscore
|
|
74
|
+
CachedMetadata.upsert_from_api(
|
|
75
|
+
type: 'state',
|
|
76
|
+
linear_id: state['id'],
|
|
77
|
+
name: state['name'],
|
|
78
|
+
key: key,
|
|
79
|
+
color: state['color'],
|
|
80
|
+
state_type: state['type'],
|
|
81
|
+
raw_data: state
|
|
82
|
+
)
|
|
83
|
+
count += 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
{ success: true, count: count }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def sync_users
|
|
90
|
+
result = LinearApi.client.list_users
|
|
91
|
+
return { success: false, error: result.error } unless result.success?
|
|
92
|
+
|
|
93
|
+
count = 0
|
|
94
|
+
result.data.each do |user|
|
|
95
|
+
key = user['email']&.split('@')&.first || user['name'].parameterize.underscore
|
|
96
|
+
CachedMetadata.upsert_from_api(
|
|
97
|
+
type: 'user',
|
|
98
|
+
linear_id: user['id'],
|
|
99
|
+
name: user['name'],
|
|
100
|
+
key: key,
|
|
101
|
+
raw_data: user
|
|
102
|
+
)
|
|
103
|
+
count += 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
{ success: true, count: count }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Import from linear.json file
|
|
110
|
+
def import_from_json(json_path)
|
|
111
|
+
data = JSON.parse(File.read(json_path))
|
|
112
|
+
counts = { labels: 0, projects: 0, states: 0, users: 0 }
|
|
113
|
+
|
|
114
|
+
# Import labels
|
|
115
|
+
import_labels_from_json(data['labels'], counts)
|
|
116
|
+
|
|
117
|
+
# Import projects
|
|
118
|
+
data['projects']&.each do |key, project|
|
|
119
|
+
CachedMetadata.upsert_from_api(
|
|
120
|
+
type: 'project',
|
|
121
|
+
linear_id: project['id'],
|
|
122
|
+
name: project['name'],
|
|
123
|
+
key: key,
|
|
124
|
+
state_type: project['state'],
|
|
125
|
+
raw_data: project
|
|
126
|
+
)
|
|
127
|
+
counts[:projects] += 1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Import workflow states
|
|
131
|
+
data['workflow_states']&.each do |key, state|
|
|
132
|
+
CachedMetadata.upsert_from_api(
|
|
133
|
+
type: 'state',
|
|
134
|
+
linear_id: state['id'],
|
|
135
|
+
name: state['name'],
|
|
136
|
+
key: key,
|
|
137
|
+
state_type: state['type'],
|
|
138
|
+
raw_data: state
|
|
139
|
+
)
|
|
140
|
+
counts[:states] += 1
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Import users
|
|
144
|
+
data['users']&.each do |key, user|
|
|
145
|
+
CachedMetadata.upsert_from_api(
|
|
146
|
+
type: 'user',
|
|
147
|
+
linear_id: user['id'],
|
|
148
|
+
name: user['name'],
|
|
149
|
+
key: key,
|
|
150
|
+
raw_data: user
|
|
151
|
+
)
|
|
152
|
+
counts[:users] += 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
counts
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def extract_category(label_name)
|
|
161
|
+
return nil unless label_name.include?(':')
|
|
162
|
+
|
|
163
|
+
label_name.split(':').first
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def import_labels_from_json(labels_data, counts)
|
|
167
|
+
return unless labels_data
|
|
168
|
+
|
|
169
|
+
labels_data.each do |category, labels|
|
|
170
|
+
labels.each do |_key, label|
|
|
171
|
+
CachedMetadata.upsert_from_api(
|
|
172
|
+
type: 'label',
|
|
173
|
+
linear_id: label['id'],
|
|
174
|
+
name: label['name'],
|
|
175
|
+
key: label['name'],
|
|
176
|
+
color: label['color'],
|
|
177
|
+
category: category,
|
|
178
|
+
raw_data: label
|
|
179
|
+
)
|
|
180
|
+
counts[:labels] += 1
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module LinearApi
|
|
7
|
+
# GraphQL client for Linear API
|
|
8
|
+
class Client
|
|
9
|
+
API_ENDPOINT = 'https://api.linear.app/graphql'
|
|
10
|
+
|
|
11
|
+
attr_reader :api_key, :team_id
|
|
12
|
+
|
|
13
|
+
def initialize(api_key:, team_id: nil)
|
|
14
|
+
@api_key = api_key
|
|
15
|
+
@team_id = team_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Execute a GraphQL query
|
|
19
|
+
#
|
|
20
|
+
# @param query [String] GraphQL query string
|
|
21
|
+
# @param variables [Hash] Query variables
|
|
22
|
+
# @return [Result] Result object with data or error
|
|
23
|
+
def query(query, variables: {})
|
|
24
|
+
response = connection.post do |req|
|
|
25
|
+
req.body = { query: query, variables: variables }.to_json
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
handle_response(response)
|
|
29
|
+
rescue Faraday::Error => e
|
|
30
|
+
Result.new(success: false, error: "Network error: #{e.message}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Create a new issue
|
|
34
|
+
#
|
|
35
|
+
# @param title [String] Issue title
|
|
36
|
+
# @param description [String] Issue description (markdown)
|
|
37
|
+
# @param options [Hash] Additional options (priority, label_ids, project_id, etc.)
|
|
38
|
+
# @return [Result] Result with created issue
|
|
39
|
+
def create_issue(title:, description: nil, **options)
|
|
40
|
+
input = build_issue_input(title: title, description: description, **options)
|
|
41
|
+
|
|
42
|
+
result = query(Issue::CREATE_MUTATION, variables: { input: input })
|
|
43
|
+
return result if result.failure?
|
|
44
|
+
|
|
45
|
+
issue_data = result.data.dig('issueCreate', 'issue')
|
|
46
|
+
Result.new(
|
|
47
|
+
success: result.data.dig('issueCreate', 'success'),
|
|
48
|
+
data: Issue.new(issue_data),
|
|
49
|
+
raw_response: result.raw_response
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Update an existing issue
|
|
54
|
+
#
|
|
55
|
+
# @param id [String] Issue ID
|
|
56
|
+
# @param options [Hash] Fields to update
|
|
57
|
+
# @return [Result] Result with updated issue
|
|
58
|
+
def update_issue(id:, **options)
|
|
59
|
+
input = options.transform_keys { |k| camelize(k) }
|
|
60
|
+
|
|
61
|
+
result = query(Issue::UPDATE_MUTATION, variables: { id: id, input: input })
|
|
62
|
+
return result if result.failure?
|
|
63
|
+
|
|
64
|
+
issue_data = result.data.dig('issueUpdate', 'issue')
|
|
65
|
+
Result.new(
|
|
66
|
+
success: result.data.dig('issueUpdate', 'success'),
|
|
67
|
+
data: Issue.new(issue_data),
|
|
68
|
+
raw_response: result.raw_response
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get issue by identifier (e.g., "TOS-123")
|
|
73
|
+
#
|
|
74
|
+
# @param identifier [String] Issue identifier
|
|
75
|
+
# @return [Result] Result with issue
|
|
76
|
+
def get_issue(identifier)
|
|
77
|
+
result = query(Issue::SEARCH_QUERY, variables: { term: identifier })
|
|
78
|
+
return result if result.failure?
|
|
79
|
+
|
|
80
|
+
issues = result.data.dig('searchIssues', 'nodes') || []
|
|
81
|
+
if issues.empty?
|
|
82
|
+
Result.new(success: false, error: "Issue not found: #{identifier}")
|
|
83
|
+
else
|
|
84
|
+
Result.new(success: true, data: Issue.new(issues.first), raw_response: result.raw_response)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# List issues for the team
|
|
89
|
+
#
|
|
90
|
+
# @param filter [Hash] Filter options
|
|
91
|
+
# @param limit [Integer] Max issues to return
|
|
92
|
+
# @return [Result] Result with array of issues
|
|
93
|
+
def list_issues(filter: nil, limit: 50)
|
|
94
|
+
variables = { teamId: team_id, first: limit }
|
|
95
|
+
variables[:filter] = filter if filter
|
|
96
|
+
|
|
97
|
+
result = query(Issue::LIST_QUERY, variables: variables)
|
|
98
|
+
return result if result.failure?
|
|
99
|
+
|
|
100
|
+
issues = (result.data.dig('team', 'issues', 'nodes') || []).map { |i| Issue.new(i) }
|
|
101
|
+
Result.new(success: true, data: issues, raw_response: result.raw_response)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Add a comment to an issue
|
|
105
|
+
#
|
|
106
|
+
# @param issue_id [String] Issue ID
|
|
107
|
+
# @param body [String] Comment body (markdown)
|
|
108
|
+
# @return [Result] Result with comment
|
|
109
|
+
def add_comment(issue_id:, body:)
|
|
110
|
+
result = query(Issue::ADD_COMMENT_MUTATION, variables: { issueId: issue_id, body: body })
|
|
111
|
+
return result if result.failure?
|
|
112
|
+
|
|
113
|
+
Result.new(
|
|
114
|
+
success: result.data.dig('commentCreate', 'success'),
|
|
115
|
+
data: result.data.dig('commentCreate', 'comment'),
|
|
116
|
+
raw_response: result.raw_response
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Get team info
|
|
121
|
+
#
|
|
122
|
+
# @return [Result] Result with team data
|
|
123
|
+
def get_team
|
|
124
|
+
result = query(Team::GET_QUERY, variables: { teamId: team_id })
|
|
125
|
+
return result if result.failure?
|
|
126
|
+
|
|
127
|
+
team_data = result.data['team']
|
|
128
|
+
Result.new(success: true, data: Team.new(team_data), raw_response: result.raw_response)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# List projects for the team
|
|
132
|
+
#
|
|
133
|
+
# @return [Result] Result with array of projects
|
|
134
|
+
def list_projects
|
|
135
|
+
result = query(Project::LIST_QUERY, variables: { teamId: team_id })
|
|
136
|
+
return result if result.failure?
|
|
137
|
+
|
|
138
|
+
projects = (result.data.dig('team', 'projects', 'nodes') || []).map { |p| Project.new(p) }
|
|
139
|
+
Result.new(success: true, data: projects, raw_response: result.raw_response)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Create a new project
|
|
143
|
+
#
|
|
144
|
+
# @param name [String] Project name
|
|
145
|
+
# @param description [String] Optional project description
|
|
146
|
+
# @param color [String] Optional hex color (e.g., "#0052CC")
|
|
147
|
+
# @return [Result] Result with created project
|
|
148
|
+
def create_project(name:, description: nil, color: nil)
|
|
149
|
+
create_project_mutation = <<~GRAPHQL
|
|
150
|
+
mutation CreateProject($input: ProjectCreateInput!) {
|
|
151
|
+
projectCreate(input: $input) {
|
|
152
|
+
success
|
|
153
|
+
project {
|
|
154
|
+
id
|
|
155
|
+
name
|
|
156
|
+
description
|
|
157
|
+
url
|
|
158
|
+
color
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
GRAPHQL
|
|
163
|
+
|
|
164
|
+
input = { name: name, teamIds: [team_id] }
|
|
165
|
+
input[:description] = description if description
|
|
166
|
+
input[:color] = color if color
|
|
167
|
+
|
|
168
|
+
result = query(create_project_mutation, variables: { input: input })
|
|
169
|
+
return result if result.failure?
|
|
170
|
+
|
|
171
|
+
project_data = result.data.dig('projectCreate', 'project')
|
|
172
|
+
Result.new(
|
|
173
|
+
success: result.data.dig('projectCreate', 'success'),
|
|
174
|
+
data: Project.new(project_data),
|
|
175
|
+
raw_response: result.raw_response
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# List labels for the team
|
|
180
|
+
#
|
|
181
|
+
# @return [Result] Result with array of labels
|
|
182
|
+
def list_labels
|
|
183
|
+
result = query(Label::LIST_QUERY, variables: { teamId: team_id })
|
|
184
|
+
return result if result.failure?
|
|
185
|
+
|
|
186
|
+
labels = (result.data.dig('team', 'labels', 'nodes') || []).map { |l| Label.new(l) }
|
|
187
|
+
Result.new(success: true, data: labels, raw_response: result.raw_response)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Get workflow states for the team
|
|
191
|
+
#
|
|
192
|
+
# @return [Result] Result with array of states
|
|
193
|
+
def list_states
|
|
194
|
+
result = query(Team::STATES_QUERY, variables: { teamId: team_id })
|
|
195
|
+
return result if result.failure?
|
|
196
|
+
|
|
197
|
+
states = result.data.dig('team', 'states', 'nodes') || []
|
|
198
|
+
Result.new(success: true, data: states, raw_response: result.raw_response)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# ==========================================================================
|
|
202
|
+
# Convenience Methods for Common Operations
|
|
203
|
+
# ==========================================================================
|
|
204
|
+
|
|
205
|
+
# Move an issue to a different state
|
|
206
|
+
#
|
|
207
|
+
# @param id [String] Issue ID
|
|
208
|
+
# @param state_id [String] Target state ID
|
|
209
|
+
# @return [Result] Result with updated issue
|
|
210
|
+
def move_to_state(id:, state_id:)
|
|
211
|
+
update_issue(id: id, state_id: state_id)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Add labels to an issue (appends to existing)
|
|
215
|
+
#
|
|
216
|
+
# @param id [String] Issue ID
|
|
217
|
+
# @param label_ids [Array<String>] Label IDs to add
|
|
218
|
+
# @return [Result] Result with updated issue
|
|
219
|
+
def add_labels(id:, label_ids:)
|
|
220
|
+
# First get current labels
|
|
221
|
+
issue_result = get_issue_by_id(id)
|
|
222
|
+
return issue_result if issue_result.failure?
|
|
223
|
+
|
|
224
|
+
current_ids = issue_result.data.labels.map { |l| l['id'] }
|
|
225
|
+
new_ids = (current_ids + label_ids).uniq
|
|
226
|
+
|
|
227
|
+
update_issue(id: id, label_ids: new_ids)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Remove labels from an issue
|
|
231
|
+
#
|
|
232
|
+
# @param id [String] Issue ID
|
|
233
|
+
# @param label_ids [Array<String>] Label IDs to remove
|
|
234
|
+
# @return [Result] Result with updated issue
|
|
235
|
+
def remove_labels(id:, label_ids:)
|
|
236
|
+
issue_result = get_issue_by_id(id)
|
|
237
|
+
return issue_result if issue_result.failure?
|
|
238
|
+
|
|
239
|
+
current_ids = issue_result.data.labels.map { |l| l['id'] }
|
|
240
|
+
new_ids = current_ids - label_ids
|
|
241
|
+
|
|
242
|
+
update_issue(id: id, label_ids: new_ids)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Assign issue to a user
|
|
246
|
+
#
|
|
247
|
+
# @param id [String] Issue ID
|
|
248
|
+
# @param assignee_id [String] User ID (nil to unassign)
|
|
249
|
+
# @return [Result] Result with updated issue
|
|
250
|
+
def assign(id:, assignee_id:)
|
|
251
|
+
update_issue(id: id, assignee_id: assignee_id)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Link a GitHub PR to an issue
|
|
255
|
+
#
|
|
256
|
+
# @param issue_id [String] Issue ID
|
|
257
|
+
# @param pr_url [String] GitHub PR URL
|
|
258
|
+
# @return [Result] Result with attachment
|
|
259
|
+
def link_pr(issue_id:, pr_url:)
|
|
260
|
+
mutation = <<~GRAPHQL
|
|
261
|
+
mutation LinkPR($issueId: String!, $url: String!) {
|
|
262
|
+
attachmentLinkURL(issueId: $issueId, url: $url) {
|
|
263
|
+
success
|
|
264
|
+
attachment {
|
|
265
|
+
id
|
|
266
|
+
url
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
GRAPHQL
|
|
271
|
+
|
|
272
|
+
result = query(mutation, variables: { issueId: issue_id, url: pr_url })
|
|
273
|
+
return result if result.failure?
|
|
274
|
+
|
|
275
|
+
Result.new(
|
|
276
|
+
success: result.data.dig('attachmentLinkURL', 'success'),
|
|
277
|
+
data: result.data.dig('attachmentLinkURL', 'attachment'),
|
|
278
|
+
raw_response: result.raw_response
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# List team members for assignment
|
|
283
|
+
#
|
|
284
|
+
# @return [Result] Result with array of users
|
|
285
|
+
def list_users
|
|
286
|
+
users_query = <<~GRAPHQL
|
|
287
|
+
query ListUsers($teamId: String!) {
|
|
288
|
+
team(id: $teamId) {
|
|
289
|
+
members {
|
|
290
|
+
nodes {
|
|
291
|
+
id
|
|
292
|
+
name
|
|
293
|
+
email
|
|
294
|
+
displayName
|
|
295
|
+
active
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
GRAPHQL
|
|
301
|
+
|
|
302
|
+
result = query(users_query, variables: { teamId: team_id })
|
|
303
|
+
return result if result.failure?
|
|
304
|
+
|
|
305
|
+
users = result.data.dig('team', 'members', 'nodes') || []
|
|
306
|
+
Result.new(success: true, data: users, raw_response: result.raw_response)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Archive an issue (soft delete)
|
|
310
|
+
#
|
|
311
|
+
# @param id [String] Issue ID
|
|
312
|
+
# @return [Result] Result with success status
|
|
313
|
+
def archive_issue(id:)
|
|
314
|
+
archive_mutation = <<~GRAPHQL
|
|
315
|
+
mutation ArchiveIssue($id: String!) {
|
|
316
|
+
issueArchive(id: $id) {
|
|
317
|
+
success
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
GRAPHQL
|
|
321
|
+
|
|
322
|
+
result = query(archive_mutation, variables: { id: id })
|
|
323
|
+
return result if result.failure?
|
|
324
|
+
|
|
325
|
+
Result.new(
|
|
326
|
+
success: result.data.dig('issueArchive', 'success'),
|
|
327
|
+
data: { archived: true },
|
|
328
|
+
raw_response: result.raw_response
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Unarchive an issue
|
|
333
|
+
#
|
|
334
|
+
# @param id [String] Issue ID
|
|
335
|
+
# @return [Result] Result with success status
|
|
336
|
+
def unarchive_issue(id:)
|
|
337
|
+
unarchive_mutation = <<~GRAPHQL
|
|
338
|
+
mutation UnarchiveIssue($id: String!) {
|
|
339
|
+
issueUnarchive(id: $id) {
|
|
340
|
+
success
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
GRAPHQL
|
|
344
|
+
|
|
345
|
+
result = query(unarchive_mutation, variables: { id: id })
|
|
346
|
+
return result if result.failure?
|
|
347
|
+
|
|
348
|
+
Result.new(
|
|
349
|
+
success: result.data.dig('issueUnarchive', 'success'),
|
|
350
|
+
data: { archived: false },
|
|
351
|
+
raw_response: result.raw_response
|
|
352
|
+
)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Create a new label
|
|
356
|
+
#
|
|
357
|
+
# @param name [String] Label name (e.g., "type:bug", "area:sync")
|
|
358
|
+
# @param color [String] Hex color (e.g., "#ff0000")
|
|
359
|
+
# @param description [String] Optional description
|
|
360
|
+
# @return [Result] Result with created label
|
|
361
|
+
def create_label(name:, color: nil, description: nil)
|
|
362
|
+
create_label_mutation = <<~GRAPHQL
|
|
363
|
+
mutation CreateLabel($input: IssueLabelCreateInput!) {
|
|
364
|
+
issueLabelCreate(input: $input) {
|
|
365
|
+
success
|
|
366
|
+
issueLabel {
|
|
367
|
+
id
|
|
368
|
+
name
|
|
369
|
+
color
|
|
370
|
+
description
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
GRAPHQL
|
|
375
|
+
|
|
376
|
+
input = { name: name, teamId: team_id }
|
|
377
|
+
input[:color] = color if color
|
|
378
|
+
input[:description] = description if description
|
|
379
|
+
|
|
380
|
+
result = query(create_label_mutation, variables: { input: input })
|
|
381
|
+
return result if result.failure?
|
|
382
|
+
|
|
383
|
+
label_data = result.data.dig('issueLabelCreate', 'issueLabel')
|
|
384
|
+
Result.new(
|
|
385
|
+
success: result.data.dig('issueLabelCreate', 'success'),
|
|
386
|
+
data: Label.new(label_data),
|
|
387
|
+
raw_response: result.raw_response
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Create a sub-issue (child issue)
|
|
392
|
+
#
|
|
393
|
+
# @param parent_id [String] Parent issue ID
|
|
394
|
+
# @param title [String] Sub-issue title
|
|
395
|
+
# @param options [Hash] Additional options
|
|
396
|
+
# @return [Result] Result with created sub-issue
|
|
397
|
+
def create_sub_issue(parent_id:, title:, **options)
|
|
398
|
+
create_issue(title: title, parent_id: parent_id, **options)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Get sub-issues for an issue
|
|
402
|
+
#
|
|
403
|
+
# @param parent_id [String] Parent issue ID
|
|
404
|
+
# @return [Result] Result with array of sub-issues
|
|
405
|
+
def list_sub_issues(parent_id:)
|
|
406
|
+
sub_issues_query = <<~GRAPHQL
|
|
407
|
+
query GetSubIssues($id: String!) {
|
|
408
|
+
issue(id: $id) {
|
|
409
|
+
children {
|
|
410
|
+
nodes {
|
|
411
|
+
id
|
|
412
|
+
identifier
|
|
413
|
+
title
|
|
414
|
+
state {
|
|
415
|
+
id
|
|
416
|
+
name
|
|
417
|
+
}
|
|
418
|
+
priority
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
GRAPHQL
|
|
424
|
+
|
|
425
|
+
result = query(sub_issues_query, variables: { id: parent_id })
|
|
426
|
+
return result if result.failure?
|
|
427
|
+
|
|
428
|
+
children = (result.data.dig('issue', 'children', 'nodes') || []).map { |i| Issue.new(i) }
|
|
429
|
+
Result.new(success: true, data: children, raw_response: result.raw_response)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Get issue by UUID (not identifier)
|
|
433
|
+
#
|
|
434
|
+
# @param id [String] Issue UUID
|
|
435
|
+
# @return [Result] Result with issue
|
|
436
|
+
def get_issue_by_id(id)
|
|
437
|
+
issue_query = <<~GRAPHQL
|
|
438
|
+
query GetIssueById($id: String!) {
|
|
439
|
+
issue(id: $id) {
|
|
440
|
+
id
|
|
441
|
+
identifier
|
|
442
|
+
title
|
|
443
|
+
description
|
|
444
|
+
url
|
|
445
|
+
priority
|
|
446
|
+
state {
|
|
447
|
+
id
|
|
448
|
+
name
|
|
449
|
+
type
|
|
450
|
+
}
|
|
451
|
+
labels {
|
|
452
|
+
nodes {
|
|
453
|
+
id
|
|
454
|
+
name
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
assignee {
|
|
458
|
+
id
|
|
459
|
+
name
|
|
460
|
+
}
|
|
461
|
+
project {
|
|
462
|
+
id
|
|
463
|
+
name
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
GRAPHQL
|
|
468
|
+
|
|
469
|
+
result = query(issue_query, variables: { id: id })
|
|
470
|
+
return result if result.failure?
|
|
471
|
+
|
|
472
|
+
issue_data = result.data['issue']
|
|
473
|
+
if issue_data.nil?
|
|
474
|
+
Result.new(success: false, error: "Issue not found: #{id}")
|
|
475
|
+
else
|
|
476
|
+
Result.new(success: true, data: Issue.new(issue_data), raw_response: result.raw_response)
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
private
|
|
481
|
+
|
|
482
|
+
def connection
|
|
483
|
+
@connection ||= Faraday.new(url: API_ENDPOINT) do |f|
|
|
484
|
+
f.headers['Content-Type'] = 'application/json'
|
|
485
|
+
f.headers['Authorization'] = api_key
|
|
486
|
+
f.adapter Faraday.default_adapter
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def handle_response(response)
|
|
491
|
+
data = JSON.parse(response.body)
|
|
492
|
+
|
|
493
|
+
if data['errors']
|
|
494
|
+
error_message = data['errors'].map { |e| e['message'] }.join(', ')
|
|
495
|
+
return Result.new(success: false, error: error_message, raw_response: data)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
Result.new(success: true, data: data['data'], raw_response: data)
|
|
499
|
+
rescue JSON::ParserError => e
|
|
500
|
+
Result.new(success: false, error: "Invalid JSON: #{e.message}")
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def build_issue_input(title:, description: nil, **options)
|
|
504
|
+
input = { title: title, teamId: team_id }
|
|
505
|
+
input[:description] = description if description
|
|
506
|
+
|
|
507
|
+
# Map Ruby snake_case to GraphQL camelCase
|
|
508
|
+
options.each do |key, value|
|
|
509
|
+
input[camelize(key)] = value
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
input
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def camelize(key)
|
|
516
|
+
key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }.to_sym
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|