Skip to content

Commit 736e7cb

Browse files
committed
Refactors CLI modules for more DRY
1 parent 641701a commit 736e7cb

15 files changed

Lines changed: 334 additions & 88 deletions

File tree

Gemfile.lock

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
linear-cli (0.3.10)
4+
linear-cli (0.3.11)
55
base64 (~> 0.2)
66
dry-cli (~> 1.0)
77
dry-cli-completion (~> 1.0)
@@ -11,6 +11,7 @@ PATH
1111
sequel (~> 5.0)
1212
sqlite3 (~> 1.7)
1313
tty-markdown (~> 0.7)
14+
tty-prompt (~> 0.23)
1415

1516
GEM
1617
remote: https://rubygems.org/
@@ -173,16 +174,25 @@ GEM
173174
ffi (~> 1.1)
174175
thor (1.3.0)
175176
tty-color (0.6.0)
177+
tty-cursor (0.7.1)
176178
tty-markdown (0.7.2)
177179
kramdown (>= 1.16.2, < 3.0)
178180
pastel (~> 0.8)
179181
rouge (>= 3.14, < 5.0)
180182
strings (~> 0.2.0)
181183
tty-color (~> 0.5)
182184
tty-screen (~> 0.8)
185+
tty-prompt (0.23.1)
186+
pastel (~> 0.8)
187+
tty-reader (~> 0.8)
188+
tty-reader (0.9.0)
189+
tty-cursor (~> 0.7)
190+
tty-screen (~> 0.8)
191+
wisper (~> 2.0)
183192
tty-screen (0.8.2)
184193
unicode-display_width (2.5.0)
185194
unicode_utils (1.4.0)
195+
wisper (2.0.1)
186196

187197
PLATFORMS
188198
ruby

lib/linear/cli.rb

Lines changed: 28 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,72 +3,55 @@
33
require 'dry/cli'
44
require 'dry/cli/completion/command'
55
require_relative '../linear'
6+
require 'semantic_logger'
67
require 'tty-markdown'
8+
require 'tty-prompt'
79

810
# The Rubyists module is the top-level namespace for all Rubyists projects
911
module Rubyists
1012
module Linear
1113
# The CLI module is a Dry::CLI::Registry that contains all the commands
1214
module CLI
15+
include SemanticLogger::Loggable
1316
extend Dry::CLI::Registry
1417

15-
# Watch for the call method to be added to a command
16-
module Watcher
17-
def self.extended(_mod)
18-
define_method :method_added do |method_name|
19-
return unless method_name == :call
20-
21-
prepend Rubyists::Linear::CLI::Caller
22-
end
23-
end
18+
def self.prompt
19+
@prompt ||= TTY::Prompt.new
2420
end
2521

26-
# The CommonOptions module contains common options for all commands
27-
module CommonOptions
28-
def self.included(mod)
29-
mod.instance_eval do
30-
extend Rubyists::Linear::CLI::Watcher
31-
option :output, type: :string, default: 'text', values: %w[text json], desc: 'Output format'
32-
option :debug, type: :integer, default: 0, desc: 'Debug level'
33-
end
22+
def self.register_sub!(command, sub_file, klass)
23+
# The filename is expected to define a class of the same name, but capitalized
24+
name = sub_file.basename('.rb').to_s
25+
subklass = klass.const_get(name.capitalize)
26+
if (aliases = klass::ALIASES[name.to_sym])
27+
command.register name, subklass, aliases: Array(aliases)
28+
else
29+
command.register name, subklass
3430
end
31+
end
3532

36-
def display(subject, options)
37-
return puts(JSON.pretty_generate(subject)) if options[:output] == 'json'
38-
return subject.each { |s| s.display(options) } if subject.respond_to?(:each)
39-
unless subject.respond_to?(:display)
40-
raise SmellsBad, "Cannot display #{subject}, there is no #display method and it is not a collection"
41-
end
42-
43-
subject.display(options)
33+
def self.register_subcommands!(command, name, klass)
34+
Pathname.new(__FILE__).dirname.join("commands/#{name}").glob('*.rb').each do |file|
35+
require file.expand_path
36+
register_sub! command, file, klass
4437
end
4538
end
4639

47-
# This module is prepended to all commands to log their calls
48-
module Caller
49-
def self.prepended(_mod) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
50-
Caller.class_eval do
51-
define_method :call do |**method_args| # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
52-
debug = method_args[:debug].to_i
53-
Rubyists::Linear.verbosity = debug
54-
logger.trace "Calling #{self.class} with #{method_args}"
55-
super(**method_args)
56-
rescue SmellsBad => e
57-
logger.error e.message
58-
exit 1
59-
rescue NotFoundError => e
60-
logger.error e.message
61-
rescue StandardError => e
62-
logger.error e.message
63-
logger.error e.backtrace.join("\n") if Rubyists::Linear.verbosity.positive?
64-
exit 5
65-
end
66-
end
40+
def self.load_and_register!(command)
41+
logger.debug "Registering #{command}"
42+
name = command.name.split('::').last.downcase
43+
command_aliases = command::ALIASES[name.to_sym] || []
44+
register name, aliases: Array(command_aliases) do |cmd|
45+
register_subcommands! cmd, name, command
6746
end
6847
end
6948
end
7049
end
7150

51+
Pathname.new(__FILE__).dirname.join('cli').glob('*.rb').each do |file|
52+
require file.expand_path
53+
end
54+
7255
# Load all our commands
7356
Pathname.new(__FILE__).dirname.join('commands').glob('*.rb').each do |file|
7457
require file.expand_path

lib/linear/cli/caller.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
module Rubyists
4+
module Linear
5+
module CLI
6+
# This module is prepended to all commands to log their calls
7+
module Caller
8+
def self.prepended(mod) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
9+
# Global options for all commands
10+
mod.instance_eval do
11+
option :output, type: :string, default: 'text', values: %w[text json], desc: 'Output format'
12+
option :debug, type: :integer, default: 0, desc: 'Debug level'
13+
end
14+
Caller.class_eval do
15+
# Wraps the :call method so the debug option is honored, and we can trace the call
16+
# as well as handle any exceptions that are raised
17+
define_method :call do |**method_args| # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
18+
debug = method_args[:debug].to_i
19+
Rubyists::Linear.verbosity = debug
20+
logger.trace "Calling #{self.class} with #{method_args}"
21+
super(**method_args)
22+
rescue SmellsBad => e
23+
logger.error e.message
24+
exit 1
25+
rescue NotFoundError => e
26+
logger.error e.message
27+
rescue StandardError => e
28+
logger.error e.message
29+
logger.error e.backtrace.join("\n") if Rubyists::Linear.verbosity.positive?
30+
exit 5
31+
end
32+
end
33+
end
34+
end
35+
end
36+
end
37+
end

lib/linear/cli/common_options.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module Rubyists
4+
module Linear
5+
module CLI
6+
# The CommonOptions module contains common options for all commands
7+
module CommonOptions
8+
def self.included(mod)
9+
mod.instance_eval do
10+
extend Rubyists::Linear::CLI::Watcher
11+
end
12+
end
13+
14+
def display(subject, options)
15+
return puts(JSON.pretty_generate(subject)) if options[:output] == 'json'
16+
return subject.each { |s| s.display(options) } if subject.respond_to?(:each)
17+
unless subject.respond_to?(:display)
18+
raise SmellsBad, "Cannot display #{subject}, there is no #display method and it is not a collection"
19+
end
20+
21+
subject.display(options)
22+
end
23+
end
24+
end
25+
end
26+
end

lib/linear/cli/sub_commands.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# frozen_string_literal: true
2+
3+
module Rubyists
4+
module Linear
5+
module CLI
6+
# The SubCommands module should be included in all commands with subcommands
7+
module SubCommands
8+
def self.included(mod)
9+
mod.instance_eval do
10+
def const_added(const)
11+
return unless const == :ALIASES
12+
13+
Rubyists::Linear::CLI.load_and_register! self
14+
end
15+
end
16+
end
17+
18+
def ask_for_team # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
19+
teams = Rubyists::Linear::Team.mine
20+
if teams.size == 1
21+
logger.info('Only one team found, using it', team: teams.first.name)
22+
teams.first
23+
elsif teams.empty?
24+
logger.error('No teams found for you. Please join a team or pass an existing team name.')
25+
raise SmellsBad, 'No team given and none found for you'
26+
else
27+
prompt.on(:keypress) do |event|
28+
prompt.trigger(:keydown) if event.value == 'j'
29+
prompt.trigger(:keyup) if event.value == 'k'
30+
end
31+
key = prompt.select('Choose a team', teams.to_h { |t| [t.name, t.key] })
32+
Rubyists::Linear::Team.find key
33+
end
34+
end
35+
36+
def prompt
37+
@prompt ||= CLI.prompt
38+
end
39+
40+
def team_for(key = nil)
41+
return Team.find(key) if key
42+
43+
ask_for_team
44+
end
45+
46+
def description_for(description = nil)
47+
return description if description
48+
49+
prompt.multiline('Description:')
50+
end
51+
52+
def title_for(title = nil)
53+
return title if title
54+
55+
prompt.ask('Title:')
56+
end
57+
58+
def labels_for(team, labels = nil)
59+
return Rubyists::Linear::Label.find_all_by_name(labels.map(&:strip)) if labels
60+
61+
prompt.multi_select('Labels:', team.labels.to_h { |t| [t.name, t] })
62+
end
63+
end
64+
end
65+
end
66+
end

lib/linear/cli/watcher.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module Rubyists
4+
module Linear
5+
module CLI
6+
# Watch for the call method to be added to a command
7+
module Watcher
8+
def self.extended(_mod)
9+
define_method :method_added do |method_name|
10+
return unless method_name == :call
11+
12+
prepend Rubyists::Linear::CLI::Caller
13+
end
14+
end
15+
end
16+
end
17+
end
18+
end

lib/linear/commands/issue.rb

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,38 @@
11
# frozen_string_literal: true
22

3+
require_relative '../cli/sub_commands'
4+
35
module Rubyists
46
module Linear
57
# The Cli module is defined in cli.rb and is the top-level namespace for all CLI commands
68
module CLI
9+
# The Issue module is the namespace for all issue-related commands, and
10+
# should be included in any command that deals with issues
711
module Issue
8-
# This ALIASES hash will return the key as the value if the key is not found,
9-
# otherwise it will return the value of the existing key
10-
ALIASES = Hash.new { |h, k| h[k] = k }.merge(
11-
'list' => 'ls'
12-
)
13-
end
12+
include CLI::SubCommands
13+
# Aliases for Issue commands
14+
ALIASES = {
15+
create: %w[new add c], # aliases for the create command
16+
list: %w[l ls], # aliases for the list command
17+
show: %w[s view v display d], # aliases for the show command
18+
issue: %w[i issues] # aliases for the main issue command itself
19+
}.freeze
20+
21+
def make_da_issue!(**options)
22+
# These *_for methods are defined in Rubyists::Linear::CLI::SubCommands
23+
title = title_for options[:title]
24+
description = description_for options[:description]
25+
team = team_for options[:team]
26+
labels = labels_for team, options[:labels]
27+
Rubyists::Linear::Issue.create(title:, description:, team:, labels:)
28+
end
1429

15-
Pathname.new(__FILE__).dirname.join('issue').glob('*.rb').each do |file|
16-
require file.expand_path
17-
register 'issue', aliases: %w[i] do |issue|
18-
basename = File.basename(file, '.rb')
19-
# The filename is expected to define a class of the same name, but capitalized
20-
issue.register Issue::ALIASES[basename], Issue.const_get(basename.capitalize)
30+
def gimme_da_issue!(issue_id, me) # rubocop:disable Naming/MethodParameterName
31+
issue = Rubyists::Linear::Issue.find(issue_id)
32+
logger.debug 'Taking issue', issue:, assignee: me
33+
updated = issue.assign! me
34+
logger.debug 'Issue taken', issue: updated
35+
updated
2136
end
2237
end
2338
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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, :label
9+
# Namespace for CLI
10+
module CLI
11+
module Issue
12+
Create = Class.new Dry::CLI::Command
13+
# The Create class is a Dry::CLI::Command to create a new issue
14+
class Create
15+
include SemanticLogger::Loggable
16+
include Rubyists::Linear::CLI::CommonOptions
17+
include Rubyists::Linear::CLI::Issue # for #gimme_da_issue and other methods
18+
desc 'Create a new issue'
19+
option :title, type: :string, aliases: ['-t'], desc: 'Issue Title'
20+
option :team, type: :string, aliases: ['-T'], desc: 'Team Identifier'
21+
option :description, type: :string, aliases: ['-d'], desc: 'Issue Description'
22+
option :labels, type: :array, aliases: ['-l'], desc: 'Labels for the issue (Comma separated list)'
23+
24+
def call(**options)
25+
logger.debug('Creating issue', options:)
26+
issue = make_da_issue!(**options)
27+
logger.debug('Issue created', issue:)
28+
end
29+
end
30+
end
31+
end
32+
end
33+
end

lib/linear/commands/issue/take.rb

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,10 @@ class Take
1717
desc 'Assign one or more issues to yourself'
1818
argument :issue_ids, type: :array, required: true, desc: 'Issue Identifiers'
1919

20-
def gimme_da_issue(issue_id, me) # rubocop:disable Naming/MethodParameterName
21-
issue = Rubyists::Linear::Issue.find(issue_id)
22-
logger.debug 'Taking issue', issue:, assignee: me
23-
updated = issue.assign! me
24-
logger.debug 'Issue taken', issue: updated
25-
updated
26-
end
27-
2820
def call(issue_ids:, **options)
2921
me = Rubyists::Linear::User.me
3022
updates = issue_ids.map do |issue_id|
31-
gimme_da_issue issue_id, me
23+
gimme_da_issue! issue_id, me # gimme_da_issue! is defined in Rubyists::Linear::CLI::Issue
3224
rescue NotFoundError => e
3325
logger.warn e.message
3426
next

0 commit comments

Comments
 (0)