diff --git a/exe/lc b/exe/lc index a4cc322..c383d76 100755 --- a/exe/lc +++ b/exe/lc @@ -1,4 +1,8 @@ #!/usr/bin/env ruby # frozen_string_literal: true -exec File.join(__dir__, 'lc.sh'), *ARGV +require 'pathname' +script_dir = Pathname(__dir__).join('scripts') +basename = File.basename(__FILE__) +script = script_dir.join(basename).exist? ? script_dir.join(basename) : script_dir.join("#{basename}.sh") +exec script.to_s, *ARGV diff --git a/exe/lclose b/exe/lclose deleted file mode 100755 index 2259720..0000000 --- a/exe/lclose +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -exec File.join(__dir__, 'lclose.sh'), *ARGV diff --git a/exe/lclose b/exe/lclose new file mode 120000 index 0000000..398d0d1 --- /dev/null +++ b/exe/lclose @@ -0,0 +1 @@ +lc \ No newline at end of file diff --git a/exe/lcls b/exe/lcls deleted file mode 100755 index a06435d..0000000 --- a/exe/lcls +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -exec File.join(__dir__, 'lcls.sh'), *ARGV diff --git a/exe/lcls b/exe/lcls new file mode 120000 index 0000000..398d0d1 --- /dev/null +++ b/exe/lcls @@ -0,0 +1 @@ +lc \ No newline at end of file diff --git a/exe/lcls.sh b/exe/lcls.sh deleted file mode 100755 index 9e9e1d0..0000000 --- a/exe/lcls.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -exec linear-cli issue list "$@" diff --git a/exe/lcreate b/exe/lcreate deleted file mode 100755 index 6878dd9..0000000 --- a/exe/lcreate +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -exec File.join(__dir__, 'lcreate.sh'), *ARGV diff --git a/exe/lcreate b/exe/lcreate new file mode 120000 index 0000000..398d0d1 --- /dev/null +++ b/exe/lcreate @@ -0,0 +1 @@ +lc \ No newline at end of file diff --git a/exe/lc.sh b/exe/scripts/lc.sh similarity index 100% rename from exe/lc.sh rename to exe/scripts/lc.sh diff --git a/exe/lclose.sh b/exe/scripts/lclose.sh similarity index 100% rename from exe/lclose.sh rename to exe/scripts/lclose.sh diff --git a/exe/scripts/lcls.sh b/exe/scripts/lcls.sh new file mode 100755 index 0000000..71ee051 --- /dev/null +++ b/exe/scripts/lcls.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec lc issue list "$@" diff --git a/exe/scripts/lcomment.sh b/exe/scripts/lcomment.sh new file mode 100755 index 0000000..bc8357e --- /dev/null +++ b/exe/scripts/lcomment.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec linear-cli issue update --comment - "$@" diff --git a/exe/lcreate.sh b/exe/scripts/lcreate.sh similarity index 100% rename from exe/lcreate.sh rename to exe/scripts/lcreate.sh diff --git a/lib/linear/api.rb b/lib/linear/api.rb index 0b10793..4822874 100644 --- a/lib/linear/api.rb +++ b/lib/linear/api.rb @@ -42,7 +42,7 @@ def call(body) def query(query) call format('{ "query": %s }', query.to_s.to_json) - rescue StandardError => e + rescue SmellsBad => e logger.error('Error in query', query:, error: e) raise e unless Rubyists::Linear.verbosity > 2 diff --git a/lib/linear/cli/sub_commands.rb b/lib/linear/cli/sub_commands.rb index 84b0a04..28ff0ef 100644 --- a/lib/linear/cli/sub_commands.rb +++ b/lib/linear/cli/sub_commands.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true +# This is where all the _for methods live +require_relative 'what_for' + module Rubyists module Linear module CLI # The SubCommands module should be included in all commands with subcommands module SubCommands + include CLI::WhatFor + def self.included(mod) mod.instance_eval do def const_added(const) @@ -41,73 +46,6 @@ def prompt @prompt ||= CLI.prompt end - def team_for(key = nil) - return Rubyists::Linear::Team.find(key) if key - - ask_for_team - end - - def reason_for(reason = nil, four: nil) - return reason if reason - - question = four ? "Reason for #{four}:" : 'Reason:' - prompt.ask(question) - end - - def cancelled_state_for(thingy) - states = thingy.cancelled_states - return states.first if states.size == 1 - - selection = prompt.select('Choose a cancelled state', states.to_h { |s| [s.name, s.id] }) - Rubyists::Linear::WorkflowState.find selection - end - - def completed_state_for(thingy) - states = thingy.completed_states - return states.first if states.size == 1 - - selection = prompt.select('Choose a completed state', states.to_h { |s| [s.name, s.id] }) - Rubyists::Linear::WorkflowState.find selection - end - - def description_for(description = nil) - return description if description - - prompt.multiline('Description:').map(&:chomp).join('\\n') - end - - def title_for(title = nil) - return title if title - - prompt.ask('Title:') - end - - def labels_for(team, labels = nil) - return Rubyists::Linear::Label.find_all_by_name(labels.map(&:strip)) if labels - - prompt.on(:keypress) do |event| - prompt.trigger(:keydown) if event.value == 'j' - prompt.trigger(:keyup) if event.value == 'k' - end - prompt.multi_select('Labels:', team.labels.to_h { |t| [t.name, t] }) - end - - def cut_branch!(branch_name) - if current_branch != default_branch - prompt.yes?("You are not on the default branch (#{default_branch}). Do you want to checkout #{default_branch} and create a new branch?") && git.checkout(default_branch) # rubocop:disable Layout/LineLength - end - git.branch(branch_name) - end - - def branch_for(branch_name) - logger.trace('Looking for branch', branch_name:) - existing = git.branches[branch_name] - return cut_branch!(branch_name) unless existing - - logger.trace('Branch found', branch: existing&.name) - existing - end - def current_branch git.current_branch end diff --git a/lib/linear/cli/what_for.rb b/lib/linear/cli/what_for.rb new file mode 100644 index 0000000..59f1e0c --- /dev/null +++ b/lib/linear/cli/what_for.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Rubyists + module Linear + module CLI + # Module for the _for methods + module WhatFor + def editor_for(prefix) + file = Tempfile.open(prefix, Rubyists::Linear.tmpdir) + TTY::Editor.open(file.path) + file.close + File.readlines(file.path).map(&:chomp).join('\\n') + ensure + file&.close + end + + def comment_for(issue, comment) + return comment unless comment.nil? || comment == '-' + + comment = prompt.ask("Comment for #{issue.identifier} - #{issue.title} (- to open an editor)", default: '-') + return comment unless comment == '-' + + editor_for %w[comment .md] + end + + def team_for(key = nil) + return Rubyists::Linear::Team.find(key) if key + + ask_for_team + end + + def reason_for(reason = nil, four: nil) + return reason if reason + + question = four ? "Reason for #{four}:" : 'Reason:' + prompt.ask(question) + end + + def cancelled_state_for(thingy) + states = thingy.cancelled_states + return states.first if states.size == 1 + + selection = prompt.select('Choose a cancelled state', states.to_h { |s| [s.name, s.id] }) + Rubyists::Linear::WorkflowState.find selection + end + + def completed_state_for(thingy) + states = thingy.completed_states + return states.first if states.size == 1 + + selection = prompt.select('Choose a completed state', states.to_h { |s| [s.name, s.id] }) + Rubyists::Linear::WorkflowState.find selection + end + + def description_for(description = nil) + return description if description + + prompt.multiline('Description:').map(&:chomp).join('\\n') + end + + def title_for(title = nil) + return title if title + + prompt.ask('Title:') + end + + def labels_for(team, labels = nil) + return Rubyists::Linear::Label.find_all_by_name(labels.map(&:strip)) if labels + + prompt.on(:keypress) do |event| + prompt.trigger(:keydown) if event.value == 'j' + prompt.trigger(:keyup) if event.value == 'k' + end + prompt.multi_select('Labels:', team.labels.to_h { |t| [t.name, t] }) + end + end + end + end +end diff --git a/lib/linear/commands/issue.rb b/lib/linear/commands/issue.rb index 77478b5..2543c76 100644 --- a/lib/linear/commands/issue.rb +++ b/lib/linear/commands/issue.rb @@ -30,7 +30,7 @@ module Issue }.freeze def issue_comment(issue, comment) - issue.add_comment comment + issue.add_comment comment_for(issue, comment) prompt.ok "Comment added to #{issue.identifier}" end @@ -121,7 +121,8 @@ def make_da_issue!(**options) description = description_for(options[:description]) team = team_for(options[:team]) labels = labels_for(team, options[:labels]) - Rubyists::Linear::Issue.create(title:, description:, team:, labels:) + project = project_for(team, options[:project]) + Rubyists::Linear::Issue.create(title:, description:, team:, labels:, project:) end def gimme_da_issue!(issue_id, me: Rubyists::Linear::User.me) # rubocop:disable Naming/MethodParameterName diff --git a/lib/linear/commands/issue/create.rb b/lib/linear/commands/issue/create.rb index 83d2531..47ff647 100644 --- a/lib/linear/commands/issue/create.rb +++ b/lib/linear/commands/issue/create.rb @@ -21,6 +21,7 @@ class Create option :description, type: :string, aliases: ['-d'], desc: 'Issue Description' option :team, type: :string, aliases: ['-T'], desc: 'Team Identifier' option :labels, type: :array, aliases: ['-l'], desc: 'Labels for the issue (Comma separated list)' + option :project, type: :string, aliases: ['-p'], desc: 'Project Identifier' option :develop, type: :boolean, aliases: ['-D', '--dev'], desc: 'Start development after creating the issue' def call(**options) diff --git a/lib/linear/commands/issue/update.rb b/lib/linear/commands/issue/update.rb index 38d900b..2a4df8c 100644 --- a/lib/linear/commands/issue/update.rb +++ b/lib/linear/commands/issue/update.rb @@ -19,11 +19,11 @@ class Update include Rubyists::Linear::CLI::Issue # for #gimme_da_issue! and other Issue methods desc 'Update an issue' argument :issue_ids, type: :array, required: true, desc: 'Issue IDs (i.e. CRY-1)' - option :comment, type: :string, aliases: ['-m'], desc: 'Comment to add to the issue' - option :pr, type: :boolean, aliases: ['--pull-request'], default: false, desc: 'Create a pull request' + option :comment, type: :string, aliases: ['-m'], desc: 'Comment to add to the issue. - openan editor' + option :project, type: :string, aliases: ['-p'], desc: 'Project to move the issue to. - select from a list' option :cancel, type: :boolean, default: false, desc: 'Cancel the issue' option :close, type: :boolean, default: false, desc: 'Close the issue' - option :reason, type: :string, aliases: ['--butwhy'], desc: 'Reason for closing the issue' + option :reason, type: :string, aliases: ['--butwhy'], desc: 'Reason for closing the issue. - open an editor' option :trash, type: :boolean, default: false, @@ -31,7 +31,8 @@ class Update example [ '--comment "This is a comment" CRY-1 CRY2 # Add a comment to multiple issues', - '--pr CRY-10 # Create a pull request for the issue', + '--comment - CRY-1 CRY2 # Add a comment to multiple issues, open an editor', + '--project "Manhattan" CRY-3 CRY-4 # Move tickets to a different project', '--close CRY-2 # Close an issue. Will be prompted for a reason', '--close --reason "Done" CRY-1 CRY-2 # Close multiple issues with a reason', '--cancel --trash --reason "Garbage" CRY-2 # Cancel an issue, and throw it in the trash' diff --git a/lib/linear/models/base_model/class_methods.rb b/lib/linear/models/base_model/class_methods.rb index a97ab6e..00fbeee 100644 --- a/lib/linear/models/base_model/class_methods.rb +++ b/lib/linear/models/base_model/class_methods.rb @@ -5,27 +5,55 @@ module Linear class BaseModel # Class methods for Linear models. module ClassMethods - def many_to_one(relation, klass) + def setter!(relation, klass) + define_method "#{relation}=" do |val| + hash = val.is_a?(Hash) ? val : val.updated_data + updated_data[relation] = hash + instance_variable_set("@#{relation}", Rubyists::Linear.const_get(klass).new(hash)) + end + end + + def getter!(relation) define_method relation do return instance_variable_get("@#{relation}") if instance_variable_defined?("@#{relation}") - return unless (val = data[relation]) - instance_variable_set("@#{relation}", Rubyists::Linear.const_get(klass).new(val)) + return unless (val = updated_data[relation]) + + send("#{relation}=", val) end + end + + def many_to_one(relation, klass = nil) + klass ||= relation.to_s.camelize.to_sym + getter! relation + setter! relation, klass + end + + alias one_to_one many_to_one + def many_setter!(relation, klass) define_method "#{relation}=" do |val| - hash = val.is_a?(Hash) ? val : val.data - updated_data[relation] = hash - instance_variable_set("@#{relation}", Rubyists::Linear.const_get(klass).new(hash)) + vals = if val&.key?(:nodes) + val[:nodes] + else + Array(val) + end + updated_data[relation] = vals.map { |v| v.is_a?(Hash) ? v : v.updated_data } + new_relations = vals.map { |v| v.is_a?(Hash) ? Rubyists::Linear.const_get(klass).new(v) : v } + instance_variable_set("@#{relation}", new_relations) end end - alias one_to_one many_to_one + def one_to_many(relation, klass = nil) + klass ||= relation.to_s.singularize.camelize.to_sym + getter! relation + many_setter! relation, klass + end def find(id_val) camel_name = just_name.camelize :lower - bf = base_fragment - query_data = Api.query(query { __node(camel_name, id: id_val) { ___ bf } }) + ff = full_fragment + query_data = Api.query(query { __node(camel_name, id: id_val) { ___ ff } }) new query_data[camel_name.to_sym] end @@ -63,6 +91,10 @@ def base_fragment const_get(:Base) end + def full_fragment + base_fragment + end + def basic_filter return const_get(:BASIC_FILTER) if const_defined?(:BASIC_FILTER) diff --git a/lib/linear/models/issue.rb b/lib/linear/models/issue.rb index d20c2e1..892fd9e 100644 --- a/lib/linear/models/issue.rb +++ b/lib/linear/models/issue.rb @@ -5,13 +5,15 @@ module Rubyists # Namespace for Linear module Linear - M :base_model, :user + M :base_model Issue = Class.new(BaseModel) + M 'issue/class_methods' # The Issue class represents a Linear issue. - class Issue # rubocop:disable Metrics/ClassLength + class Issue include SemanticLogger::Loggable - one_to_one :assignee, :User - one_to_one :team, :Team + extend ClassMethods + many_to_one :assignee, :User + many_to_one :team, :Team BASIC_FILTER = { completedAt: { null: true } }.freeze @@ -25,38 +27,6 @@ class Issue # rubocop:disable Metrics/ClassLength updatedAt end - class << self - def base_fragment - @base_fragment ||= fragment('IssueWithTeams', 'Issue') do - ___ Base - assignee { ___ User.base_fragment } - team { ___ Team.base_fragment } - end - end - - def find(slug) - q = query { issue(id: slug) { ___ Issue.base_fragment } } - data = Api.query(q) - raise NotFoundError, "Issue not found: #{slug}" if data.nil? - - new(data[:issue]) - end - - def find_all(*slugs) - slugs.flatten.map { |slug| find(slug) } - end - - def create(title:, description:, team:, labels: []) - team_id = team.id - label_ids = labels.map(&:id) - input = { title:, description:, teamId: team_id } - input[:labelIds] = label_ids unless label_ids.empty? - m = mutation { issueCreate(input:) { issue { ___ Issue.base_fragment } } } - query_data = Api.query(m) - new query_data.dig(:issueCreate, :issue) - end - end - def comment_fragment @comment_fragment ||= fragment('Comment', 'Comment') do id @@ -81,7 +51,7 @@ def close_mutation(close_state, trash: false) id_for_this = identifier input = { stateId: close_state.id } input[:trash] = true if trash - mutation { issueUpdate(id: id_for_this, input:) { issue { ___ Issue.base_fragment } } } + mutation { issueUpdate(id: id_for_this, input:) { issue { ___ Issue.full_fragment } } } end def close!(state: nil, trash: false) @@ -97,7 +67,7 @@ def close!(state: nil, trash: false) def assign!(user) this_id = identifier - m = mutation { issueUpdate(id: this_id, input: { assigneeId: user.id }) { issue { ___ Issue.base_fragment } } } + m = mutation { issueUpdate(id: this_id, input: { assigneeId: user.id }) { issue { ___ Issue.full_fragment } } } query_data = Api.query(m) updated = query_data.dig(:issueUpdate, :issue) raise SmellsBad, "Unknown response for issue update: #{data} (should have :issueUpdate key)" if updated.nil? diff --git a/lib/linear/models/issue/class_methods.rb b/lib/linear/models/issue/class_methods.rb new file mode 100644 index 0000000..1b2020b --- /dev/null +++ b/lib/linear/models/issue/class_methods.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Rubyists + # Namespace for Linear + module Linear + M :user, :team + # The Issue class represents a Linear issue. + class Issue + # Class methods for Issue + module ClassMethods + def base_fragment + @base_fragment ||= fragment('BaseIssue', 'Issue') do + ___ Base + assignee { ___ User.base_fragment } + team { ___ Team.base_fragment } + end + end + + def full_fragment + @full_fragment ||= fragment('FullIssue', 'Issue') do + ___ Base + assignee { ___ User.full_fragment } + team { ___ Team.full_fragment } + end + end + + def find_all(*slugs) + slugs.flatten.map { |slug| find(slug) } + end + + def create(title:, description:, team:, project:, labels: []) + team_id = team.id + label_ids = labels.map(&:id) + input = { title:, description:, teamId: team_id } + input[:labelIds] = label_ids unless label_ids.empty? + input[:projectId] = project.id if project + m = mutation { issueCreate(input:) { issue { ___ Issue.base_fragment } } } + query_data = Api.query(m) + new query_data.dig(:issueCreate, :issue) + end + end + end + end +end diff --git a/lib/linear/models/project.rb b/lib/linear/models/project.rb new file mode 100644 index 0000000..cf4da62 --- /dev/null +++ b/lib/linear/models/project.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'gqli' + +module Rubyists + # Namespace for Linear + module Linear + M :base_model + Project = Class.new(BaseModel) + # The Project class represents a Linear workflow state. + class Project + include SemanticLogger::Loggable + + Base = fragment('BaseProject', 'Project') do + id + name + content + slugId + description + url + createdAt + updatedAt + end + + def slug + File.basename(url).sub("-#{slugId}", '') + end + + def match_score?(string) + downed = string.downcase + return 100 if downed.split.join('-') == slug || downed == name.downcase + return 75 if name.include?(string) || slug.include?(downed) + return 50 if description.downcase.include?(downed) + + 0 + end + + def to_s + format('%-12s %s', name:, url:) + end + + def inspection + format('name: "%s" type: "%s"', name:, url:) + end + end + end +end diff --git a/lib/linear/models/team.rb b/lib/linear/models/team.rb index 776e293..77d388c 100644 --- a/lib/linear/models/team.rb +++ b/lib/linear/models/team.rb @@ -5,11 +5,12 @@ module Rubyists # Namespace for Linear module Linear - M :base_model, :issue, :user, :workflow_state + M :base_model, :issue, :project, :workflow_state, :user Team = Class.new(BaseModel) # The Issue class represents a Linear issue. class Team include SemanticLogger::Loggable + one_to_many :projects # TODO: Make this configurable BaseFilter = { # rubocop:disable Naming/ConstantName @@ -29,15 +30,11 @@ class Team updatedAt end - def self.find(key) - q = query do - team(id: key) { ___ Base } + def self.full_fragment + @full_fragment ||= fragment('WholeTeam', 'Team') do + ___ Base + projects { nodes { ___ Project.base_fragment } } end - data = Api.query(q) - hash = data[:team] - raise NotFoundError, "Team not found: #{key}" unless hash - - new hash end def self.mine