linear-cli 0.3.3

Sign up to get free protection for your applications and to get access to all the features.
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: []