linear-cli 0.3.3
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 +5 -0
- data/LICENSE.txt +21 -0
- data/Rakefile +12 -0
- data/Readme.adoc +44 -0
- data/exe/lc +6 -0
- data/exe/lcls +4 -0
- data/exe/lcls.sh +2 -0
- data/lib/linear/api.rb +59 -0
- data/lib/linear/cli/version.rb +7 -0
- data/lib/linear/cli.rb +82 -0
- data/lib/linear/commands/issue/list.rb +40 -0
- data/lib/linear/commands/issue/take.rb +38 -0
- data/lib/linear/commands/issue.rb +23 -0
- data/lib/linear/commands/team/list.rb +40 -0
- data/lib/linear/commands/team.rb +5 -0
- data/lib/linear/commands/whoami.rb +29 -0
- data/lib/linear/exceptions.rb +8 -0
- data/lib/linear/fragments.rb +18 -0
- data/lib/linear/models/base_model.rb +128 -0
- data/lib/linear/models/issue.rb +70 -0
- data/lib/linear/models/team.rb +55 -0
- data/lib/linear/models/user.rb +104 -0
- data/lib/linear/version.rb +1 -0
- data/lib/linear.rb +50 -0
- data/linear-cli.gemspec +48 -0
- data/sig/linear/cli.rbs +6 -0
- metadata +189 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 6bf813e3a28137493d9fd80024f4cde731f89a52fb493ca03df4d1f9721b375b
|
4
|
+
data.tar.gz: d27e556e89afe79be576ad568232f1e0b9978ce4bb1b4774413d6eabbfeab958
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4da602c640c58d688a65c281f5380bebb104e2ab5674ec4764dc34fa20b0375c7038fabdf0accd51014c090abfc093fa8638133b157806ad77e9c18674a307a4
|
7
|
+
data.tar.gz: e57503dd7dd5d9328411b67a6b563b522544636606ac94fe7681411c9d9e881641ea1b51c99398dc2823c748c059b17c54d2cf1d676170a50d86d19623fe7244
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Tj (bougyman) Vanderpoel
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/Rakefile
ADDED
data/Readme.adoc
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
= Linear Command line interface
|
2
|
+
:toc: right
|
3
|
+
:toclevels: 2
|
4
|
+
:sectanchors:
|
5
|
+
:icons: font
|
6
|
+
:experimental:
|
7
|
+
|
8
|
+
A command line interface to https://linear.app.
|
9
|
+
|
10
|
+
== Installation
|
11
|
+
|
12
|
+
=== From Source
|
13
|
+
|
14
|
+
[source,sh]
|
15
|
+
----
|
16
|
+
$ git clone https://github.com/rubyists/linear-cli.git
|
17
|
+
$ cd linear-cli
|
18
|
+
$ bundle install
|
19
|
+
$ rake install
|
20
|
+
----
|
21
|
+
|
22
|
+
== Usage
|
23
|
+
|
24
|
+
=== Configuration
|
25
|
+
|
26
|
+
You must set the LINEAR_API_KEY environment variable to your Linear API key. You can find your API key at https://linear.app/settings/api.
|
27
|
+
|
28
|
+
=== Commands
|
29
|
+
|
30
|
+
==== Who Am I?
|
31
|
+
|
32
|
+
[source,sh]
|
33
|
+
----
|
34
|
+
$ lc whoami
|
35
|
+
$ lc whoami --teams
|
36
|
+
----
|
37
|
+
|
38
|
+
==== List Issues
|
39
|
+
|
40
|
+
[source,sh]
|
41
|
+
----
|
42
|
+
$ lcls
|
43
|
+
$ lcls --full
|
44
|
+
----
|
data/exe/lc
ADDED
data/exe/lcls
ADDED
data/exe/lcls.sh
ADDED
data/lib/linear/api.rb
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'httpx'
|
4
|
+
require 'semantic_logger'
|
5
|
+
|
6
|
+
module Rubyists
|
7
|
+
module Linear
|
8
|
+
# Responsible for making requests to the Linear API
|
9
|
+
class GraphApi
|
10
|
+
include SemanticLogger::Loggable
|
11
|
+
BASE_URI = 'https://api.linear.app/graphql'
|
12
|
+
RETRY_AFTER = lambda do |*|
|
13
|
+
@retries ||= 0
|
14
|
+
@retries += 1
|
15
|
+
seconds = @retries * 2
|
16
|
+
logger.warn("Retry number #{@retries}, retrying after #{seconds} seconds")
|
17
|
+
seconds
|
18
|
+
end
|
19
|
+
|
20
|
+
def session
|
21
|
+
return @session if @session
|
22
|
+
|
23
|
+
@session = HTTPX.plugin(:retries, retry_after: RETRY_AFTER, max_retries: 5).with(headers:)
|
24
|
+
end
|
25
|
+
|
26
|
+
def headers
|
27
|
+
@headers ||= {
|
28
|
+
'Content-Type' => 'application/json',
|
29
|
+
'Authorization' => api_key
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
def call(body)
|
34
|
+
res = session.post(BASE_URI, body:)
|
35
|
+
raise SmellsBad, "Bad Response from #{BASE_URI}: #{res}" if res.error
|
36
|
+
|
37
|
+
data = JSON.parse(res.body.read, symbolize_names: true)
|
38
|
+
raise SmellsBad, "No Data Returned for #{body}" unless data&.key?(:data)
|
39
|
+
|
40
|
+
data[:data]
|
41
|
+
end
|
42
|
+
|
43
|
+
def mutation(mutation)
|
44
|
+
q = format('{ "query": "%s" }', mutation.to_s.gsub("\n", '').gsub('"', '\"'))
|
45
|
+
require 'pry' ; binding.pry
|
46
|
+
call q
|
47
|
+
end
|
48
|
+
|
49
|
+
def query(query)
|
50
|
+
call format('{ "query": "%s" }', query.to_s.gsub("\n", '').gsub('"', '\"'))
|
51
|
+
end
|
52
|
+
|
53
|
+
def api_key
|
54
|
+
@api_key ||= ENV.fetch('LINEAR_API_KEY')
|
55
|
+
end
|
56
|
+
end
|
57
|
+
Api = Rubyists::Linear::GraphApi.new
|
58
|
+
end
|
59
|
+
end
|
data/lib/linear/cli.rb
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry/cli'
|
4
|
+
require 'dry/cli/completion/command'
|
5
|
+
require_relative '../linear'
|
6
|
+
|
7
|
+
# The Rubyists module is the top-level namespace for all Rubyists projects
|
8
|
+
module Rubyists
|
9
|
+
module Linear
|
10
|
+
# The CLI module is a Dry::CLI::Registry that contains all the commands
|
11
|
+
module CLI
|
12
|
+
extend Dry::CLI::Registry
|
13
|
+
|
14
|
+
# Watch for the call method to be added to a command
|
15
|
+
module Watcher
|
16
|
+
def self.extended(_mod)
|
17
|
+
define_method :method_added do |method_name|
|
18
|
+
return unless method_name == :call
|
19
|
+
|
20
|
+
prepend Rubyists::Linear::CLI::Caller
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# The CommonOptions module contains common options for all commands
|
26
|
+
module CommonOptions
|
27
|
+
def self.included(mod)
|
28
|
+
mod.instance_eval do
|
29
|
+
extend Rubyists::Linear::CLI::Watcher
|
30
|
+
option :output, type: :string, default: 'text', values: %w[text json], desc: 'Output format'
|
31
|
+
option :debug, type: :integer, default: 0, desc: 'Debug level'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def display(subject, options)
|
36
|
+
return puts(JSON.pretty_generate(subject)) if options[:output] == 'json'
|
37
|
+
return subject.each { |s| s.display(options) } if subject.respond_to?(:each)
|
38
|
+
unless subject.respond_to?(:display)
|
39
|
+
raise SmellsBad, "Cannot display #{subject}, there is no #display method and it is not a collection"
|
40
|
+
end
|
41
|
+
|
42
|
+
subject.display(options)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# This module is prepended to all commands to log their calls
|
47
|
+
module Caller
|
48
|
+
def self.prepended(_mod) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
49
|
+
Caller.class_eval do
|
50
|
+
define_method :call do |**method_args| # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
51
|
+
debug = method_args[:debug].to_i
|
52
|
+
Rubyists::Linear.verbosity = debug
|
53
|
+
logger.trace "Calling #{self.class} with #{method_args}"
|
54
|
+
super(**method_args)
|
55
|
+
rescue SmellsBad => e
|
56
|
+
logger.error e.message
|
57
|
+
exit 1
|
58
|
+
rescue NotFoundError => e
|
59
|
+
logger.error e.message
|
60
|
+
rescue StandardError => e
|
61
|
+
logger.error e.message
|
62
|
+
logger.error e.backtrace.join("\n") if Rubyists::Linear.verbosity.positive?
|
63
|
+
exit 5
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Load all our commands
|
72
|
+
Pathname.new(__FILE__).dirname.join('commands').glob('*.rb').each do |file|
|
73
|
+
require file.expand_path
|
74
|
+
end
|
75
|
+
|
76
|
+
module Linear
|
77
|
+
# Open this back up to register 3rd party/other commands
|
78
|
+
module CLI
|
79
|
+
register 'completion', Dry::CLI::Completion::Command[self]
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'semantic_logger'
|
4
|
+
|
5
|
+
module Rubyists
|
6
|
+
# Namespace for Linear
|
7
|
+
module Linear
|
8
|
+
M :issue
|
9
|
+
M :user
|
10
|
+
# Namespace for CLI
|
11
|
+
module CLI
|
12
|
+
module Issue
|
13
|
+
List = Class.new Dry::CLI::Command
|
14
|
+
# The List class is a Dry::CLI::Command that lists issues
|
15
|
+
class List
|
16
|
+
include SemanticLogger::Loggable
|
17
|
+
include Rubyists::Linear::CLI::CommonOptions
|
18
|
+
|
19
|
+
option :mine, type: :boolean, default: true, desc: 'Only show my issues'
|
20
|
+
option :unassigned, aliases: ['-u'], type: :boolean, default: false, desc: 'Show unassigned issues only'
|
21
|
+
option :full, type: :boolean, aliases: ['-f'], default: false, desc: 'Show full issue details'
|
22
|
+
|
23
|
+
def call(**options)
|
24
|
+
logger.debug 'Listing issues'
|
25
|
+
|
26
|
+
display issues_for(options), options
|
27
|
+
end
|
28
|
+
|
29
|
+
def issues_for(options)
|
30
|
+
logger.debug('Fetching issues', options:)
|
31
|
+
return Rubyists::Linear::Issue.all(filter: { assignee: { null: true } }) if options[:unassigned]
|
32
|
+
return Rubyists::Linear::User.me.issues if options[:mine]
|
33
|
+
|
34
|
+
Rubyists::Linear::Issue.all
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'semantic_logger'
|
4
|
+
|
5
|
+
module Rubyists
|
6
|
+
# Namespace for Linear
|
7
|
+
module Linear
|
8
|
+
M :issue, :user
|
9
|
+
# Namespace for CLI
|
10
|
+
module CLI
|
11
|
+
module Issue
|
12
|
+
Take = Class.new Dry::CLI::Command
|
13
|
+
# The Take class is a Dry::CLI::Command that assigns an issue to yourself
|
14
|
+
class Take
|
15
|
+
include SemanticLogger::Loggable
|
16
|
+
include Rubyists::Linear::CLI::CommonOptions
|
17
|
+
desc 'Assign one or more issues to yourself'
|
18
|
+
argument :issue_ids, type: :array, required: true, desc: 'Issue Identifiers'
|
19
|
+
|
20
|
+
def call(issue_ids:, **options) # rubocop:disable Metrics/MethodLength
|
21
|
+
me = Rubyists::Linear::User.me
|
22
|
+
updates = issue_ids.map do |issue_id|
|
23
|
+
issue = Rubyists::Linear::Issue.find(issue_id)
|
24
|
+
logger.debug 'Taking issue', issue:, assignee: me
|
25
|
+
updated = issue.assign! me
|
26
|
+
logger.debug 'Issue taken', issue: updated
|
27
|
+
updated
|
28
|
+
rescue NotFoundError => e
|
29
|
+
logger.error e.message
|
30
|
+
next
|
31
|
+
end.compact
|
32
|
+
display updates, options
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rubyists
|
4
|
+
module Linear
|
5
|
+
# The Cli module is defined in cli.rb and is the top-level namespace for all CLI commands
|
6
|
+
module CLI
|
7
|
+
# This ALIASES hash will return the key as the value if the key is not found,
|
8
|
+
# otherwise it will return the value of the existing key
|
9
|
+
ALIASES = Hash.new { |h, k| h[k] = k }.merge(
|
10
|
+
'list' => 'ls'
|
11
|
+
)
|
12
|
+
|
13
|
+
Pathname.new(__FILE__).dirname.join('issue').glob('*.rb').each do |file|
|
14
|
+
require file.expand_path
|
15
|
+
register 'issue', aliases: %w[i] do |issue|
|
16
|
+
basename = File.basename(file, '.rb')
|
17
|
+
# The filename is expected to define a class of the same name, but capitalized
|
18
|
+
issue.register ALIASES[basename], Issue.const_get(basename.capitalize)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'semantic_logger'
|
4
|
+
|
5
|
+
module Rubyists
|
6
|
+
# Namespace for Linear
|
7
|
+
module Linear
|
8
|
+
M :team, :issue
|
9
|
+
# Namespace for CLI
|
10
|
+
module CLI
|
11
|
+
module Team
|
12
|
+
List = Class.new Dry::CLI::Command
|
13
|
+
# The List class is a Dry::CLI::Command that lists issues
|
14
|
+
class List
|
15
|
+
include SemanticLogger::Loggable
|
16
|
+
include Rubyists::Linear::CLI::CommonOptions
|
17
|
+
|
18
|
+
option :mine, type: :boolean, default: true, desc: 'Only show my issues'
|
19
|
+
|
20
|
+
def call(**options)
|
21
|
+
logger.debug 'Listing teams'
|
22
|
+
display teams_for(options), options
|
23
|
+
end
|
24
|
+
|
25
|
+
def teams_for(options)
|
26
|
+
return Rubyists::Linear::Team.mine if options[:mine]
|
27
|
+
|
28
|
+
Rubyists::Linear::Team.all
|
29
|
+
end
|
30
|
+
|
31
|
+
prepend Rubyists::Linear::CLI::Caller
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
register 'team', aliases: %w[t] do |team|
|
36
|
+
team.register 'ls', Team::List
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'semantic_logger'
|
4
|
+
|
5
|
+
module Rubyists
|
6
|
+
# Namespace for Linear
|
7
|
+
module Linear
|
8
|
+
M :user
|
9
|
+
# Namespace for CLI
|
10
|
+
module CLI
|
11
|
+
WhoAmI = Class.new Dry::CLI::Command
|
12
|
+
# The WhoAmI command
|
13
|
+
class WhoAmI
|
14
|
+
include SemanticLogger::Loggable
|
15
|
+
include Rubyists::Linear::CLI::CommonOptions
|
16
|
+
|
17
|
+
option :teams, type: :boolean, default: false, desc: 'Show teams'
|
18
|
+
|
19
|
+
def call(**options)
|
20
|
+
logger.debug 'Getting user info'
|
21
|
+
display Rubyists::Linear::User.me(teams: options[:teams]), options
|
22
|
+
end
|
23
|
+
|
24
|
+
prepend Rubyists::Linear::CLI::Caller
|
25
|
+
end
|
26
|
+
register 'whoami', WhoAmI
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gqli'
|
4
|
+
|
5
|
+
module Rubyists
|
6
|
+
module Linear
|
7
|
+
# Reusable fragments
|
8
|
+
module Fragments
|
9
|
+
extend GQLi::DSL
|
10
|
+
PageInfo = fragment('PageInfo', 'PageInfo') do
|
11
|
+
pageInfo do
|
12
|
+
hasNextPage
|
13
|
+
endCursor
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gqli'
|
4
|
+
require 'semantic_logger'
|
5
|
+
require 'sequel/extensions/inflector'
|
6
|
+
|
7
|
+
module Rubyists
|
8
|
+
module Linear
|
9
|
+
# Module which provides a base model for Linear models.
|
10
|
+
class BaseModel
|
11
|
+
extend GQLi::DSL
|
12
|
+
include GQLi::DSL
|
13
|
+
include SemanticLogger::Loggable
|
14
|
+
|
15
|
+
# Methods for Linear models.
|
16
|
+
module MethodMagic
|
17
|
+
def self.included(base) # rubocop:disable Metrics/MethodLength
|
18
|
+
base.instance_eval do
|
19
|
+
base.base_fragment.__nodes.each do |node|
|
20
|
+
sym = node.__name.to_sym
|
21
|
+
define_method node.__name do
|
22
|
+
updated_data[sym]
|
23
|
+
end
|
24
|
+
|
25
|
+
define_method "#{node.__name}=" do |value|
|
26
|
+
updated_data[sym] = value
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Class methods for Linear models.
|
34
|
+
class << self
|
35
|
+
def const_added(const)
|
36
|
+
return unless const == :Base
|
37
|
+
|
38
|
+
include MethodMagic
|
39
|
+
end
|
40
|
+
|
41
|
+
def allq(filter: nil, limit: 50, after: nil)
|
42
|
+
args = { first: limit }
|
43
|
+
args[:filter] = filter ? basic_filter.merge(filter) : basic_filter
|
44
|
+
args.delete(:filter) if args[:filter].empty?
|
45
|
+
args[:after] = after if after
|
46
|
+
all_query args, plural.to_s, base_fragment
|
47
|
+
end
|
48
|
+
|
49
|
+
def all_query(args, subject, base_fragment)
|
50
|
+
query do
|
51
|
+
__node(subject, args) do
|
52
|
+
edges do
|
53
|
+
node { ___ base_fragment }
|
54
|
+
cursor
|
55
|
+
end
|
56
|
+
___ Fragments::PageInfo
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def just_name
|
62
|
+
name.split('::').last
|
63
|
+
end
|
64
|
+
|
65
|
+
def base_fragment
|
66
|
+
const_get(:Base)
|
67
|
+
end
|
68
|
+
|
69
|
+
def basic_filter
|
70
|
+
return const_get(:BASIC_FILTER) if const_defined?(:BASIC_FILTER)
|
71
|
+
|
72
|
+
{}
|
73
|
+
end
|
74
|
+
|
75
|
+
def plural
|
76
|
+
return const_get(:PLURAL) if const_defined?(:PLURAL)
|
77
|
+
|
78
|
+
just_name.downcase.pluralize.to_sym
|
79
|
+
end
|
80
|
+
|
81
|
+
def gql_query(filter: nil, after: nil)
|
82
|
+
Api.query(allq(filter:, after:))
|
83
|
+
end
|
84
|
+
|
85
|
+
def all(after: nil, filter: nil, max: 100)
|
86
|
+
edges = []
|
87
|
+
moar = true
|
88
|
+
while moar
|
89
|
+
data = gql_query(filter:, after:)
|
90
|
+
subjects = data[plural]
|
91
|
+
edges += subjects[:edges]
|
92
|
+
moar = false if edges.size >= max || !subjects[:pageInfo][:hasNextPage]
|
93
|
+
after = subjects[:pageInfo][:endCursor]
|
94
|
+
end
|
95
|
+
edges.map { |edge| new edge[:node] }
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
attr_reader :data, :updated_data
|
100
|
+
|
101
|
+
def initialize(data)
|
102
|
+
data.each_key { |k| raise SmellsBad, "Unknown key #{k}" unless respond_to? "#{k}=" }
|
103
|
+
@data = data
|
104
|
+
@updated_data = data.dup
|
105
|
+
end
|
106
|
+
|
107
|
+
def changed?
|
108
|
+
data != updated_data
|
109
|
+
end
|
110
|
+
|
111
|
+
def to_h
|
112
|
+
updated_data
|
113
|
+
end
|
114
|
+
|
115
|
+
def to_json(*_args)
|
116
|
+
updated_data.to_json
|
117
|
+
end
|
118
|
+
|
119
|
+
def inspection
|
120
|
+
format('name: "%<name>s"', name:)
|
121
|
+
end
|
122
|
+
|
123
|
+
def inspect
|
124
|
+
format '#<%<name>s:%<id>s %<inspection>s>', name: self.class.name, id:, inspection:
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gqli'
|
4
|
+
|
5
|
+
module Rubyists
|
6
|
+
# Namespace for Linear
|
7
|
+
module Linear
|
8
|
+
L :api
|
9
|
+
L :fragments
|
10
|
+
M :base_model
|
11
|
+
M :user
|
12
|
+
Issue = Class.new(BaseModel)
|
13
|
+
# The Issue class represents a Linear issue.
|
14
|
+
class Issue
|
15
|
+
include SemanticLogger::Loggable
|
16
|
+
|
17
|
+
BASIC_FILTER = { completedAt: { null: true } }.freeze
|
18
|
+
|
19
|
+
Base = fragment('BaseIssue', 'Issue') do
|
20
|
+
id
|
21
|
+
identifier
|
22
|
+
title
|
23
|
+
assignee { ___ User::Base }
|
24
|
+
description
|
25
|
+
createdAt
|
26
|
+
updatedAt
|
27
|
+
end
|
28
|
+
|
29
|
+
class << self
|
30
|
+
def find(slug)
|
31
|
+
q = query { issue(id: slug) { ___ Base } }
|
32
|
+
data = Api.query(q)
|
33
|
+
raise NotFoundError, "Issue not found: #{slug}" if data.nil?
|
34
|
+
|
35
|
+
new(data[:issue])
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def assign!(user)
|
40
|
+
id_for_this = identifier
|
41
|
+
m = mutation { issueUpdate(id: id_for_this, input: { assigneeId: user.id }) { issue { ___ Base } } }
|
42
|
+
data = Api.query(m)
|
43
|
+
updated = data.dig(:issueUpdate, :issue)
|
44
|
+
raise SmellsBad, "Unknown response for issue update: #{data} (should have :issueUpdate key)" if updated.nil?
|
45
|
+
|
46
|
+
Issue.new updated
|
47
|
+
end
|
48
|
+
|
49
|
+
def inspection
|
50
|
+
format('id: "%<identifier>s" title: "%<title>s"', identifier:, title:)
|
51
|
+
end
|
52
|
+
|
53
|
+
def to_s
|
54
|
+
basic = format('%<id>-12s %<title>s', id: data[:identifier], title: data[:title])
|
55
|
+
return basic unless (name = data.dig(:assignee, :name))
|
56
|
+
|
57
|
+
format('%<basic>s (%<name>s)', basic:, name:)
|
58
|
+
end
|
59
|
+
|
60
|
+
def full
|
61
|
+
sep = '-' * to_s.length
|
62
|
+
format("%<to_s>s\n%<sep>s\n%<description>s\n", sep:, to_s:, description:)
|
63
|
+
end
|
64
|
+
|
65
|
+
def display(options)
|
66
|
+
printf "%s\n", (options[:full] ? full : to_s)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gqli'
|
4
|
+
|
5
|
+
module Rubyists
|
6
|
+
# Namespace for Linear
|
7
|
+
module Linear
|
8
|
+
L :api, :fragments
|
9
|
+
M :base_model, :issue, :user
|
10
|
+
Team = Class.new(BaseModel)
|
11
|
+
# The Issue class represents a Linear issue.
|
12
|
+
class Team
|
13
|
+
include SemanticLogger::Loggable
|
14
|
+
|
15
|
+
Base = fragment('BaseTeam', 'Team') do
|
16
|
+
description
|
17
|
+
id
|
18
|
+
key
|
19
|
+
name
|
20
|
+
createdAt
|
21
|
+
updatedAt
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.mine
|
25
|
+
User.me.teams
|
26
|
+
end
|
27
|
+
|
28
|
+
def to_s
|
29
|
+
format('%<name>s', name:)
|
30
|
+
end
|
31
|
+
|
32
|
+
def full
|
33
|
+
format('%<key>-6s %<to_s>s', key:, to_s:)
|
34
|
+
end
|
35
|
+
|
36
|
+
def members
|
37
|
+
return @members unless @members.empty?
|
38
|
+
|
39
|
+
q = query do
|
40
|
+
team(id:) do
|
41
|
+
members do
|
42
|
+
nodes { ___ User::Base }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
data = Api.query(q)
|
47
|
+
@members = data.dig(:team, :members, :nodes)&.map { |member| User.new member } || []
|
48
|
+
end
|
49
|
+
|
50
|
+
def display(_options)
|
51
|
+
printf "%s\n", full
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gqli'
|
4
|
+
|
5
|
+
module Rubyists
|
6
|
+
# Namespace for Linear
|
7
|
+
module Linear
|
8
|
+
L :api
|
9
|
+
M :base_model, :issue, :team
|
10
|
+
User = Class.new(BaseModel)
|
11
|
+
# The User class represents a Linear user.
|
12
|
+
class User
|
13
|
+
include SemanticLogger::Loggable
|
14
|
+
|
15
|
+
Base = fragment('BaseUser', 'User') do
|
16
|
+
id
|
17
|
+
name
|
18
|
+
email
|
19
|
+
end
|
20
|
+
|
21
|
+
WithTeams = fragment('UserWithTeams', 'User') do
|
22
|
+
___ Base
|
23
|
+
teams do
|
24
|
+
nodes { ___ Team::Base }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.me(teams: false)
|
29
|
+
fragment = teams ? WithTeams : Base
|
30
|
+
q = query do
|
31
|
+
viewer do
|
32
|
+
___ fragment
|
33
|
+
end
|
34
|
+
end
|
35
|
+
data = Api.query(q)
|
36
|
+
new data[:viewer]
|
37
|
+
end
|
38
|
+
|
39
|
+
def initialize(data)
|
40
|
+
super(data)
|
41
|
+
self.teams = data[:teams] if data.key? :teams
|
42
|
+
end
|
43
|
+
|
44
|
+
def issue_query(first)
|
45
|
+
id = data[:id]
|
46
|
+
query do
|
47
|
+
user(id:) do
|
48
|
+
assignedIssues(first:, filter: { completedAt: { null: true } }) do
|
49
|
+
nodes { ___ Issue::Base }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def issues(limit: 50)
|
56
|
+
issue_data = Api.query(issue_query(limit))
|
57
|
+
issue_data[:user][:assignedIssues][:nodes].map do |issue|
|
58
|
+
Issue.new issue
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def team_query(first)
|
63
|
+
id = data[:id]
|
64
|
+
query do
|
65
|
+
user(id:) do
|
66
|
+
teams(first:) do
|
67
|
+
nodes { ___ Team::Base }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def teams=(team_data)
|
74
|
+
team_data.is_a?(Array) && @teams = team_data && return
|
75
|
+
|
76
|
+
if team_data.is_a?(Hash)
|
77
|
+
@teams = team_data[:nodes].map { |team| Team.new team }
|
78
|
+
return
|
79
|
+
end
|
80
|
+
|
81
|
+
raise ArgumentError, "Don't know how to handle #{team_data.class}"
|
82
|
+
end
|
83
|
+
|
84
|
+
def teams(limit: 50)
|
85
|
+
return @teams if @teams
|
86
|
+
|
87
|
+
team_data = Api.query(team_query(limit))
|
88
|
+
@teams = team_data[:user][:teams][:nodes].map do |team|
|
89
|
+
Team.new team
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_s
|
94
|
+
format('%<id>-20s: %<name>s <%<email>s>', id:, name:, email:)
|
95
|
+
end
|
96
|
+
|
97
|
+
def display(_options)
|
98
|
+
return printf("%s\n", to_s) if @teams.nil?
|
99
|
+
|
100
|
+
printf "%<to_s>s (%<teams>s)\n", to_s:, teams: @teams.map(&:name).join(', ')
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
cli/version.rb
|
data/lib/linear.rb
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pathname'
|
4
|
+
require 'semantic_logger'
|
5
|
+
SemanticLogger.default_level = :info
|
6
|
+
SemanticLogger.add_appender(io: $stderr, formatter: :color)
|
7
|
+
|
8
|
+
# Add the / operator for path separation
|
9
|
+
class Pathname
|
10
|
+
def /(other)
|
11
|
+
join(other.to_s)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
module Rubyists
|
16
|
+
# Namespace for Linear classes
|
17
|
+
module Linear
|
18
|
+
include SemanticLogger::Loggable
|
19
|
+
# rubocop:disable Layout/SpaceAroundOperators
|
20
|
+
ROOT = (Pathname(__FILE__)/'../..').expand_path
|
21
|
+
LIBROOT = ROOT/:lib/:linear
|
22
|
+
MODEL_ROOT = ROOT/:lib/:linear/:models
|
23
|
+
SPEC_ROOT = ROOT/:spec
|
24
|
+
FEATURE_ROOT = ROOT/:features
|
25
|
+
DEBUG_LEVELS = %i[warn info debug trace].freeze
|
26
|
+
|
27
|
+
def self.L(*libraries) # rubocop:disable Naming/MethodName
|
28
|
+
Array(libraries).each { |library| require LIBROOT/library }
|
29
|
+
end
|
30
|
+
L :exceptions, :version
|
31
|
+
|
32
|
+
def self.M(*models) # rubocop:disable Naming/MethodName
|
33
|
+
Array(models).each { |model| require MODEL_ROOT/model }
|
34
|
+
end
|
35
|
+
# rubocop:enable Layout/SpaceAroundOperators
|
36
|
+
|
37
|
+
def self.verbosity
|
38
|
+
@verbosity ||= 0
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.verbosity=(debug)
|
42
|
+
return verbosity unless debug
|
43
|
+
|
44
|
+
logger.warn 'Debug level should be between 0 and 3' unless debug.between?(0, 3)
|
45
|
+
@verbosity = debug
|
46
|
+
level = @verbosity > (DEBUG_LEVELS.size - 1) ? :trace : DEBUG_LEVELS[@verbosity]
|
47
|
+
SemanticLogger.default_level = level
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/linear-cli.gemspec
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/linear'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'linear-cli'
|
7
|
+
spec.version = Rubyists::Linear::VERSION
|
8
|
+
spec.authors = ['Tj (bougyman) Vanderpoel']
|
9
|
+
spec.email = ['tj@rubyists.com']
|
10
|
+
|
11
|
+
spec.summary = 'CLI for interacting with Linear.app.'
|
12
|
+
spec.description = 'A CLI for interacting with Linear.app. Loosely based on the GitHub CLI'
|
13
|
+
spec.homepage = 'https://github.com/rubyists/linear-cli'
|
14
|
+
spec.required_ruby_version = '>= 3.2.0'
|
15
|
+
|
16
|
+
spec.license = 'MIT'
|
17
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
18
|
+
|
19
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
20
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
21
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md"
|
22
|
+
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
24
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
25
|
+
spec.files = Dir.chdir(__dir__) do
|
26
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
27
|
+
(File.expand_path(f) == __FILE__) ||
|
28
|
+
f.start_with?(*%w[pkg/ bin/ test/ spec/ features/ .git .github appveyor .rspec .rubocop cucumber.yml Gemfile])
|
29
|
+
end
|
30
|
+
end
|
31
|
+
spec.bindir = 'exe'
|
32
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
33
|
+
spec.require_paths = ['lib']
|
34
|
+
|
35
|
+
# Uncomment to register a new dependency of your gem
|
36
|
+
spec.add_dependency 'base64'
|
37
|
+
spec.add_dependency 'dry-cli', '~> 1.0'
|
38
|
+
spec.add_dependency 'dry-cli-completion', '~> 1.0'
|
39
|
+
spec.add_dependency 'gqli', '~> 1.2'
|
40
|
+
spec.add_dependency 'httpx', '~> 1.2'
|
41
|
+
spec.add_dependency 'semantic_logger', '~> 4.0'
|
42
|
+
spec.add_dependency 'sequel'
|
43
|
+
spec.add_dependency 'sqlite3'
|
44
|
+
|
45
|
+
# For more information and examples about making a new gem, check out our
|
46
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
47
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
48
|
+
end
|
data/sig/linear/cli.rbs
ADDED
metadata
ADDED
@@ -0,0 +1,189 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: linear-cli
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.3.3
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Tj (bougyman) Vanderpoel
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-02-01 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: base64
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: dry-cli
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: dry-cli-completion
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: gqli
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.2'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.2'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: httpx
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.2'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.2'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: semantic_logger
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '4.0'
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '4.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: sequel
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :runtime
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: sqlite3
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :runtime
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description: A CLI for interacting with Linear.app. Loosely based on the GitHub CLI
|
126
|
+
email:
|
127
|
+
- tj@rubyists.com
|
128
|
+
executables:
|
129
|
+
- lc
|
130
|
+
- lcls
|
131
|
+
- lcls.sh
|
132
|
+
extensions: []
|
133
|
+
extra_rdoc_files: []
|
134
|
+
files:
|
135
|
+
- CHANGELOG.md
|
136
|
+
- LICENSE.txt
|
137
|
+
- Rakefile
|
138
|
+
- Readme.adoc
|
139
|
+
- exe/lc
|
140
|
+
- exe/lcls
|
141
|
+
- exe/lcls.sh
|
142
|
+
- lib/linear.rb
|
143
|
+
- lib/linear/api.rb
|
144
|
+
- lib/linear/cli.rb
|
145
|
+
- lib/linear/cli/version.rb
|
146
|
+
- lib/linear/commands/issue.rb
|
147
|
+
- lib/linear/commands/issue/list.rb
|
148
|
+
- lib/linear/commands/issue/take.rb
|
149
|
+
- lib/linear/commands/team.rb
|
150
|
+
- lib/linear/commands/team/list.rb
|
151
|
+
- lib/linear/commands/whoami.rb
|
152
|
+
- lib/linear/exceptions.rb
|
153
|
+
- lib/linear/fragments.rb
|
154
|
+
- lib/linear/models/base_model.rb
|
155
|
+
- lib/linear/models/issue.rb
|
156
|
+
- lib/linear/models/team.rb
|
157
|
+
- lib/linear/models/user.rb
|
158
|
+
- lib/linear/version.rb
|
159
|
+
- linear-cli.gemspec
|
160
|
+
- sig/linear/cli.rbs
|
161
|
+
homepage: https://github.com/rubyists/linear-cli
|
162
|
+
licenses:
|
163
|
+
- MIT
|
164
|
+
metadata:
|
165
|
+
allowed_push_host: https://rubygems.org
|
166
|
+
homepage_uri: https://github.com/rubyists/linear-cli
|
167
|
+
source_code_uri: https://github.com/rubyists/linear-cli
|
168
|
+
changelog_uri: https://github.com/rubyists/linear-cli/blob/master/CHANGELOG.md
|
169
|
+
rubygems_mfa_required: 'true'
|
170
|
+
post_install_message:
|
171
|
+
rdoc_options: []
|
172
|
+
require_paths:
|
173
|
+
- lib
|
174
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
175
|
+
requirements:
|
176
|
+
- - ">="
|
177
|
+
- !ruby/object:Gem::Version
|
178
|
+
version: 3.2.0
|
179
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
180
|
+
requirements:
|
181
|
+
- - ">="
|
182
|
+
- !ruby/object:Gem::Version
|
183
|
+
version: '0'
|
184
|
+
requirements: []
|
185
|
+
rubygems_version: 3.5.3
|
186
|
+
signing_key:
|
187
|
+
specification_version: 4
|
188
|
+
summary: CLI for interacting with Linear.app.
|
189
|
+
test_files: []
|