linear-rb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/bin/linear +132 -0
- data/lib/linear/client.rb +31 -0
- data/lib/linear/commands.rb +182 -0
- data/lib/linear/queries.rb +127 -0
- data/lib/linear/version.rb +3 -0
- data/lib/linear.rb +7 -0
- data/readme.md +162 -0
- metadata +63 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 95f692f69f9d45f3b6f37dc1fa41a8f5312814eabb13ebfb6ed93828a7748699
|
|
4
|
+
data.tar.gz: 4176ed98a5f0b85cc30677622215ce39e1a1d0a4251dd5c88e61c67eb4d2df77
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e3b1154891eab716f95223b77a867b1a21ada9c7336a841027db055253febc21262797a7bf17c1b7cf10ca0f9c6784d212b6b85bf81e2c3b8dcf67e794b6c27b
|
|
7
|
+
data.tar.gz: 738eefde06923665d76b822818f93b5d1fe0c6e0dc30232c940d5fa73b2d1e4deb4010b3ea1eb0ee8115772b12e1d90dbb14fe36ac5d015121d051b447a3f05a
|
data/bin/linear
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require_relative '../lib/linear'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
|
|
6
|
+
def show_usage
|
|
7
|
+
puts <<~USAGE
|
|
8
|
+
Linear CLI - Ruby wrapper for Linear GraphQL API
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
linear issue ISSUE_ID Fetch issue details
|
|
12
|
+
linear search QUERY [OPTIONS] Search for issues
|
|
13
|
+
linear mine Show issues assigned to you
|
|
14
|
+
linear teams List all teams
|
|
15
|
+
linear comment ISSUE_ID COMMENT Add a comment to an issue
|
|
16
|
+
linear update ISSUE_ID STATE Update issue state
|
|
17
|
+
|
|
18
|
+
Search Options:
|
|
19
|
+
--team TEAM_KEY Filter by team key
|
|
20
|
+
--state STATE Filter by state name
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
linear issue ENG-123
|
|
24
|
+
linear search "bug fix" --team=ENG --state=Backlog
|
|
25
|
+
linear mine
|
|
26
|
+
linear comment FAT-85 "This is done"
|
|
27
|
+
linear update FAT-85 "Done"
|
|
28
|
+
|
|
29
|
+
Configuration:
|
|
30
|
+
Set the LINEAR_API_KEY environment variable with your API key from:
|
|
31
|
+
https://linear.app/settings/api
|
|
32
|
+
USAGE
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
command = ARGV.shift
|
|
36
|
+
|
|
37
|
+
case command
|
|
38
|
+
when 'issue'
|
|
39
|
+
issue_id = ARGV.shift
|
|
40
|
+
if issue_id.nil? || issue_id.empty?
|
|
41
|
+
puts "Error: issue ID required"
|
|
42
|
+
puts "Usage: linear issue ISSUE_ID"
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
Linear::Commands.fetch_issue(issue_id)
|
|
48
|
+
rescue => e
|
|
49
|
+
puts "Error: #{e.message}"
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
when 'search'
|
|
54
|
+
query = ARGV.shift
|
|
55
|
+
if query.nil? || query.empty?
|
|
56
|
+
puts "Error: search query required"
|
|
57
|
+
puts "Usage: linear search QUERY [--team TEAM] [--state STATE]"
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
options = {}
|
|
62
|
+
OptionParser.new do |opts|
|
|
63
|
+
opts.on("--team TEAM", "Filter by team key") { |v| options[:team] = v }
|
|
64
|
+
opts.on("--state STATE", "Filter by state name") { |v| options[:state] = v }
|
|
65
|
+
end.parse!
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
Linear::Commands.search(query, options)
|
|
69
|
+
rescue => e
|
|
70
|
+
puts "Error: #{e.message}"
|
|
71
|
+
exit 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
when 'mine'
|
|
75
|
+
begin
|
|
76
|
+
Linear::Commands.my_issues
|
|
77
|
+
rescue => e
|
|
78
|
+
puts "Error: #{e.message}"
|
|
79
|
+
exit 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
when 'teams'
|
|
83
|
+
begin
|
|
84
|
+
Linear::Commands.list_teams
|
|
85
|
+
rescue => e
|
|
86
|
+
puts "Error: #{e.message}"
|
|
87
|
+
exit 1
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
when 'comment'
|
|
91
|
+
issue_id = ARGV.shift
|
|
92
|
+
comment_body = ARGV.shift
|
|
93
|
+
|
|
94
|
+
if issue_id.nil? || issue_id.empty? || comment_body.nil? || comment_body.empty?
|
|
95
|
+
puts "Error: issue ID and comment text required"
|
|
96
|
+
puts "Usage: linear comment ISSUE_ID \"comment text\""
|
|
97
|
+
exit 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
Linear::Commands.add_comment(issue_id, comment_body)
|
|
102
|
+
rescue => e
|
|
103
|
+
puts "Error: #{e.message}"
|
|
104
|
+
exit 1
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
when 'update'
|
|
108
|
+
issue_id = ARGV.shift
|
|
109
|
+
state_name = ARGV.shift
|
|
110
|
+
|
|
111
|
+
if issue_id.nil? || issue_id.empty? || state_name.nil? || state_name.empty?
|
|
112
|
+
puts "Error: issue ID and state name required"
|
|
113
|
+
puts "Usage: linear update ISSUE_ID \"State Name\""
|
|
114
|
+
exit 1
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
Linear::Commands.update_issue_state(issue_id, state_name)
|
|
119
|
+
rescue => e
|
|
120
|
+
puts "Error: #{e.message}"
|
|
121
|
+
exit 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
when 'help', '--help', '-h', nil
|
|
125
|
+
show_usage
|
|
126
|
+
|
|
127
|
+
else
|
|
128
|
+
puts "Unknown command: #{command}"
|
|
129
|
+
puts ""
|
|
130
|
+
show_usage
|
|
131
|
+
exit 1
|
|
132
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
|
|
4
|
+
module Linear
|
|
5
|
+
class Client
|
|
6
|
+
BASE_URL = "https://api.linear.app/graphql"
|
|
7
|
+
|
|
8
|
+
def initialize(api_key = nil)
|
|
9
|
+
@api_key = api_key || ENV['LINEAR_API_KEY']
|
|
10
|
+
raise "No API key configured. Set LINEAR_API_KEY environment variable" unless @api_key
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def query(graphql_query, variables = {})
|
|
14
|
+
payload = { query: graphql_query, variables: variables }.to_json
|
|
15
|
+
escaped_payload = Shellwords.escape(payload)
|
|
16
|
+
|
|
17
|
+
result = `curl -s -X POST #{BASE_URL} \
|
|
18
|
+
-H "Content-Type: application/json" \
|
|
19
|
+
-H "Authorization: #{@api_key}" \
|
|
20
|
+
-d #{escaped_payload}`
|
|
21
|
+
|
|
22
|
+
parsed = JSON.parse(result)
|
|
23
|
+
|
|
24
|
+
if parsed["errors"]
|
|
25
|
+
raise "GraphQL Error: #{parsed["errors"].map { |e| e["message"] }.join(", ")}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
parsed
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
module Linear
|
|
2
|
+
module Commands
|
|
3
|
+
extend self
|
|
4
|
+
|
|
5
|
+
def fetch_issue(issue_id, client: Client.new)
|
|
6
|
+
result = client.query(Queries::ISSUE, { id: issue_id })
|
|
7
|
+
|
|
8
|
+
issue = result.dig("data", "issue")
|
|
9
|
+
if issue
|
|
10
|
+
display_issue(issue)
|
|
11
|
+
else
|
|
12
|
+
puts "Issue not found: #{issue_id}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def search(query, options = {}, client: Client.new)
|
|
17
|
+
filter = { title: { contains: query } }
|
|
18
|
+
filter[:team] = { key: { eq: options[:team] } } if options[:team]
|
|
19
|
+
filter[:state] = { name: { eq: options[:state] } } if options[:state]
|
|
20
|
+
|
|
21
|
+
result = client.query(Queries::SEARCH_ISSUES, { filter: filter })
|
|
22
|
+
|
|
23
|
+
issues = result.dig("data", "issues", "nodes") || []
|
|
24
|
+
if issues.empty?
|
|
25
|
+
puts "No issues found matching: #{query}"
|
|
26
|
+
else
|
|
27
|
+
display_issue_list(issues)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def my_issues(client: Client.new)
|
|
32
|
+
result = client.query(Queries::MY_ISSUES)
|
|
33
|
+
|
|
34
|
+
issues = result.dig("data", "viewer", "assignedIssues", "nodes") || []
|
|
35
|
+
if issues.empty?
|
|
36
|
+
puts "No issues assigned to you"
|
|
37
|
+
else
|
|
38
|
+
display_issue_list(issues)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list_teams(client: Client.new)
|
|
43
|
+
result = client.query(Queries::TEAMS)
|
|
44
|
+
|
|
45
|
+
teams = result.dig("data", "teams", "nodes") || []
|
|
46
|
+
teams.each do |team|
|
|
47
|
+
puts "#{team['key'].ljust(10)} #{team['name']}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_comment(issue_id, body, client: Client.new)
|
|
52
|
+
# First get the issue to get its internal ID
|
|
53
|
+
issue_result = client.query(Queries::ISSUE, { id: issue_id })
|
|
54
|
+
issue = issue_result.dig("data", "issue")
|
|
55
|
+
|
|
56
|
+
unless issue
|
|
57
|
+
puts "Error: Issue not found: #{issue_id}"
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
result = client.query(Queries::CREATE_COMMENT, {
|
|
62
|
+
issueId: issue['id'],
|
|
63
|
+
body: body
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if result.dig("data", "commentCreate", "success")
|
|
67
|
+
puts "Comment added to #{issue_id}"
|
|
68
|
+
else
|
|
69
|
+
puts "Error: Failed to add comment"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def update_issue_state(issue_id, state_name, client: Client.new)
|
|
74
|
+
# Get the issue details including team
|
|
75
|
+
issue_result = client.query(Queries::ISSUE, { id: issue_id })
|
|
76
|
+
issue = issue_result.dig("data", "issue")
|
|
77
|
+
|
|
78
|
+
unless issue
|
|
79
|
+
puts "Error: Issue not found: #{issue_id}"
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get team states - need to find team ID first
|
|
84
|
+
teams_result = client.query(Queries::TEAMS)
|
|
85
|
+
teams = teams_result.dig("data", "teams", "nodes") || []
|
|
86
|
+
|
|
87
|
+
# Find the team from the issue identifier prefix (e.g., "FAT" from "FAT-85")
|
|
88
|
+
team_key = issue_id.split('-').first
|
|
89
|
+
team = teams.find { |t| t['key'] == team_key }
|
|
90
|
+
|
|
91
|
+
unless team
|
|
92
|
+
puts "Error: Team not found for issue #{issue_id}"
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get workflow states for the team
|
|
97
|
+
states_result = client.query(Queries::WORKFLOW_STATES, { teamId: team['id'] })
|
|
98
|
+
states = states_result.dig("data", "team", "states", "nodes") || []
|
|
99
|
+
|
|
100
|
+
# Find the state by name (case-insensitive)
|
|
101
|
+
target_state = states.find { |s| s['name'].downcase == state_name.downcase }
|
|
102
|
+
|
|
103
|
+
unless target_state
|
|
104
|
+
puts "Error: State '#{state_name}' not found. Available states:"
|
|
105
|
+
states.each { |s| puts " - #{s['name']}" }
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Update the issue
|
|
110
|
+
result = client.query(Queries::UPDATE_ISSUE, {
|
|
111
|
+
issueId: issue['id'],
|
|
112
|
+
stateId: target_state['id']
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if result.dig("data", "issueUpdate", "success")
|
|
116
|
+
puts "Updated #{issue_id} to '#{target_state['name']}'"
|
|
117
|
+
else
|
|
118
|
+
puts "Error: Failed to update issue state"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def update_issue_description(issue_id, description, client: Client.new)
|
|
123
|
+
# Get the issue to get its internal ID
|
|
124
|
+
issue_result = client.query(Queries::ISSUE, { id: issue_id })
|
|
125
|
+
issue = issue_result.dig("data", "issue")
|
|
126
|
+
|
|
127
|
+
unless issue
|
|
128
|
+
puts "Error: Issue not found: #{issue_id}"
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Update the issue description
|
|
133
|
+
result = client.query(Queries::UPDATE_ISSUE, {
|
|
134
|
+
issueId: issue['id'],
|
|
135
|
+
description: description
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
if result.dig("data", "issueUpdate", "success")
|
|
139
|
+
puts "Updated #{issue_id} description"
|
|
140
|
+
else
|
|
141
|
+
puts "Error: Failed to update issue description"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def display_issue(issue)
|
|
148
|
+
puts "\n#{issue['identifier']}: #{issue['title']}"
|
|
149
|
+
puts "=" * 60
|
|
150
|
+
puts "Status: #{issue['state']['name']}"
|
|
151
|
+
puts "Assignee: #{issue.dig('assignee', 'name') || 'Unassigned'}"
|
|
152
|
+
puts "Priority: #{priority_label(issue['priority'])}"
|
|
153
|
+
puts "URL: #{issue['url']}"
|
|
154
|
+
puts "\nDescription:"
|
|
155
|
+
puts issue['description'] || "(no description)"
|
|
156
|
+
puts ""
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def display_issue_list(issues)
|
|
160
|
+
puts "\nFound #{issues.length} issue(s):\n\n"
|
|
161
|
+
issues.each do |issue|
|
|
162
|
+
state_badge = "[#{issue['state']['name']}]".ljust(15)
|
|
163
|
+
priority_badge = priority_label(issue['priority']).ljust(8)
|
|
164
|
+
assignee = (issue.dig('assignee', 'name') || 'Unassigned').ljust(15)
|
|
165
|
+
|
|
166
|
+
puts "#{issue['identifier'].ljust(12)} #{state_badge} #{priority_badge} #{assignee} #{issue['title']}"
|
|
167
|
+
end
|
|
168
|
+
puts ""
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def priority_label(priority)
|
|
172
|
+
case priority
|
|
173
|
+
when 0 then "None"
|
|
174
|
+
when 1 then "Urgent"
|
|
175
|
+
when 2 then "High"
|
|
176
|
+
when 3 then "Medium"
|
|
177
|
+
when 4 then "Low"
|
|
178
|
+
else "Unknown"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module Linear
|
|
2
|
+
module Queries
|
|
3
|
+
ISSUE = <<~GQL
|
|
4
|
+
query($id: String!) {
|
|
5
|
+
issue(id: $id) {
|
|
6
|
+
id
|
|
7
|
+
identifier
|
|
8
|
+
title
|
|
9
|
+
description
|
|
10
|
+
state {
|
|
11
|
+
name
|
|
12
|
+
type
|
|
13
|
+
}
|
|
14
|
+
assignee {
|
|
15
|
+
name
|
|
16
|
+
email
|
|
17
|
+
}
|
|
18
|
+
priority
|
|
19
|
+
createdAt
|
|
20
|
+
updatedAt
|
|
21
|
+
url
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
GQL
|
|
25
|
+
|
|
26
|
+
SEARCH_ISSUES = <<~GQL
|
|
27
|
+
query($filter: IssueFilter!) {
|
|
28
|
+
issues(filter: $filter) {
|
|
29
|
+
nodes {
|
|
30
|
+
id
|
|
31
|
+
identifier
|
|
32
|
+
title
|
|
33
|
+
state {
|
|
34
|
+
name
|
|
35
|
+
type
|
|
36
|
+
}
|
|
37
|
+
assignee {
|
|
38
|
+
name
|
|
39
|
+
}
|
|
40
|
+
priority
|
|
41
|
+
url
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
GQL
|
|
46
|
+
|
|
47
|
+
MY_ISSUES = <<~GQL
|
|
48
|
+
query {
|
|
49
|
+
viewer {
|
|
50
|
+
assignedIssues {
|
|
51
|
+
nodes {
|
|
52
|
+
id
|
|
53
|
+
identifier
|
|
54
|
+
title
|
|
55
|
+
state {
|
|
56
|
+
name
|
|
57
|
+
type
|
|
58
|
+
}
|
|
59
|
+
priority
|
|
60
|
+
url
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
GQL
|
|
66
|
+
|
|
67
|
+
TEAMS = <<~GQL
|
|
68
|
+
query {
|
|
69
|
+
teams {
|
|
70
|
+
nodes {
|
|
71
|
+
id
|
|
72
|
+
key
|
|
73
|
+
name
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
GQL
|
|
78
|
+
|
|
79
|
+
WORKFLOW_STATES = <<~GQL
|
|
80
|
+
query($teamId: String!) {
|
|
81
|
+
team(id: $teamId) {
|
|
82
|
+
states {
|
|
83
|
+
nodes {
|
|
84
|
+
id
|
|
85
|
+
name
|
|
86
|
+
type
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
GQL
|
|
92
|
+
|
|
93
|
+
CREATE_COMMENT = <<~GQL
|
|
94
|
+
mutation($issueId: String!, $body: String!) {
|
|
95
|
+
commentCreate(input: {
|
|
96
|
+
issueId: $issueId
|
|
97
|
+
body: $body
|
|
98
|
+
}) {
|
|
99
|
+
success
|
|
100
|
+
comment {
|
|
101
|
+
id
|
|
102
|
+
body
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
GQL
|
|
107
|
+
|
|
108
|
+
UPDATE_ISSUE = <<~GQL
|
|
109
|
+
mutation($issueId: String!, $stateId: String, $description: String) {
|
|
110
|
+
issueUpdate(id: $issueId, input: {
|
|
111
|
+
stateId: $stateId
|
|
112
|
+
description: $description
|
|
113
|
+
}) {
|
|
114
|
+
success
|
|
115
|
+
issue {
|
|
116
|
+
id
|
|
117
|
+
identifier
|
|
118
|
+
state {
|
|
119
|
+
name
|
|
120
|
+
}
|
|
121
|
+
description
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
GQL
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/linear.rb
ADDED
data/readme.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# linear-rb
|
|
2
|
+
|
|
3
|
+
A lightweight Ruby CLI wrapper for the Linear GraphQL API. Built for efficiency with zero external dependencies.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### As a gem
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install linear-rb
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Direct usage (without installing)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
./bin/linear <command>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
Set your Linear API key as an environment variable:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
export LINEAR_API_KEY=lin_api_YOUR_KEY_HERE
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or prepend each command:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
LINEAR_API_KEY=XYZ123 linear projects
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Add this to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) to make it permanent.
|
|
34
|
+
|
|
35
|
+
Get your API key from: https://linear.app/settings/api
|
|
36
|
+
|
|
37
|
+
**Security Note**: The gem never stores your API key - it only reads from the environment variable.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### View issue details
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
linear issue ENG-123
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Search for issues
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Basic search
|
|
51
|
+
linear search "authentication bug"
|
|
52
|
+
|
|
53
|
+
# Filter by team
|
|
54
|
+
linear search "bug" --team=ENG
|
|
55
|
+
|
|
56
|
+
# Filter by state
|
|
57
|
+
linear search "feature" --state=Backlog
|
|
58
|
+
|
|
59
|
+
# Combine filters
|
|
60
|
+
linear search "api" --team=ENG --state="In Progress"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### View your assigned issues
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
linear mine
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### List teams
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
linear teams
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Add a comment to an issue
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
linear comment DEV-85 "This looks good to me"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Update issue state
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Move issue to a different state
|
|
85
|
+
linear update DEV-85 "Done"
|
|
86
|
+
linear update ENG-123 "In Progress"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Help
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
linear help
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## As a Library
|
|
96
|
+
|
|
97
|
+
You can also use linear-rb as a library in your Ruby code:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
require 'linear'
|
|
101
|
+
|
|
102
|
+
# Option 1: Use LINEAR_API_KEY environment variable
|
|
103
|
+
# (No code needed - just set the env var)
|
|
104
|
+
|
|
105
|
+
# Option 2: Pass API key directly to client
|
|
106
|
+
client = Linear::Client.new('lin_api_xxx')
|
|
107
|
+
|
|
108
|
+
# Use the client directly
|
|
109
|
+
client = Linear::Client.new
|
|
110
|
+
result = client.query(Linear::Queries::ISSUE, { id: "ENG-123" })
|
|
111
|
+
|
|
112
|
+
# Or use commands
|
|
113
|
+
Linear::Commands.fetch_issue("ENG-123")
|
|
114
|
+
Linear::Commands.search("bug", team: "ENG")
|
|
115
|
+
Linear::Commands.my_issues
|
|
116
|
+
Linear::Commands.add_comment("DEV-85", "Great work!")
|
|
117
|
+
Linear::Commands.update_issue_state("ENG-85", "Done")
|
|
118
|
+
Linear::Commands.update_issue_description("MKT-85", "Updated description")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Run locally
|
|
126
|
+
./bin/linear help
|
|
127
|
+
|
|
128
|
+
# Build gem
|
|
129
|
+
gem build linear-rb.gemspec
|
|
130
|
+
|
|
131
|
+
# Install locally
|
|
132
|
+
gem install linear-rb-0.1.0.gem
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Testing
|
|
136
|
+
|
|
137
|
+
The project includes both unit tests and integration tests:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# Run all tests (skips live API tests by default)
|
|
141
|
+
rspec
|
|
142
|
+
|
|
143
|
+
# Run tests with live API integration
|
|
144
|
+
LINEAR_API_KEY=your_key rspec
|
|
145
|
+
|
|
146
|
+
# Run only integration tests
|
|
147
|
+
rspec spec/integration/
|
|
148
|
+
|
|
149
|
+
# Run specific test file
|
|
150
|
+
rspec spec/linear/client_spec.rb
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Test Structure:**
|
|
154
|
+
- `spec/linear/` - Unit tests with mocked API responses
|
|
155
|
+
- `spec/integration/` - Integration tests for the CLI binary
|
|
156
|
+
- Tests tagged with `:live` require a valid `LINEAR_API_KEY` and will make real API calls
|
|
157
|
+
- Live tests display command output and intent rather than making assertions
|
|
158
|
+
- Useful for verifying the CLI works end-to-end with real Linear data
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: linear-rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Dave Kinkead
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rspec
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.13'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.13'
|
|
26
|
+
description: Efficient command-line interface for interacting with Linear's GraphQL
|
|
27
|
+
API
|
|
28
|
+
email:
|
|
29
|
+
- hi@davekinkead.com
|
|
30
|
+
executables:
|
|
31
|
+
- linear
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- bin/linear
|
|
36
|
+
- lib/linear.rb
|
|
37
|
+
- lib/linear/client.rb
|
|
38
|
+
- lib/linear/commands.rb
|
|
39
|
+
- lib/linear/queries.rb
|
|
40
|
+
- lib/linear/version.rb
|
|
41
|
+
- readme.md
|
|
42
|
+
homepage: https://github.com/davekinkead/linear-rb
|
|
43
|
+
licenses:
|
|
44
|
+
- MIT
|
|
45
|
+
metadata: {}
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 2.7.0
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 3.7.2
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: Ruby CLI wrapper for Linear GraphQL API
|
|
63
|
+
test_files: []
|