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 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
@@ -0,0 +1,3 @@
1
+ module Linear
2
+ VERSION = "0.1.0"
3
+ end
data/lib/linear.rb ADDED
@@ -0,0 +1,7 @@
1
+ require_relative "linear/version"
2
+ require_relative "linear/client"
3
+ require_relative "linear/queries"
4
+ require_relative "linear/commands"
5
+
6
+ module Linear
7
+ end
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: []