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 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-01-24
4
+
5
+ - Initial release
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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'linear'
5
+ Rubyists::Linear::L :cli
6
+ Dry::CLI.new(Rubyists::Linear::CLI).call
data/exe/lcls ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ exec File.join(__dir__, 'lcls.sh'), *ARGV
data/exe/lcls.sh ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec lc i ls "$@"
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyists
4
+ module Linear
5
+ VERSION = '0.3.3'
6
+ end
7
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Pathname.new(__FILE__).dirname.join('team').glob('*.rb').each do |file|
4
+ require file.expand_path
5
+ 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyists
4
+ module Linear
5
+ SmellsBad = Class.new(StandardError)
6
+ NotFoundError = Class.new(StandardError)
7
+ end
8
+ 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
@@ -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
@@ -0,0 +1,6 @@
1
+ module Linear
2
+ module Cli
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
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: []