linear-cli 0.7.6 → 0.7.7

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed24311870484d37b861be6bfe48af2edfc58b2c248764dd31f3f82a4ba282a5
4
- data.tar.gz: c0404fc83e9157c97b75c1afa3fe0766a1d10838ea33e9284d380de6dde1d547
3
+ metadata.gz: 040dce1ff59ddd000240b8a03105928d775841be91d1fe50f974249e2ddc21fc
4
+ data.tar.gz: 0de33b286614b981e63ad75b14d1728e825d44e6ec2265c625a00888cdb32cf2
5
5
  SHA512:
6
- metadata.gz: 529c58e8cfa1a550e23de0ea5f3c9bb43d559f34b8c6648aa80bdc0238ee63be00282bf6166da82bf4c5be909dd5a8212e74cd74b92e42a6d1e58bc0de98e0f8
7
- data.tar.gz: ee09439dfb8d39fbec79ec0206822a9261b340b4e9c0bc15cfb529dcc68a0e3138810833250972cbbd4a30bee1149f5e280b544193dfc414cb825aefe1c20218
6
+ metadata.gz: 1f45d0fc3280fda121430d10078e49a5a870753a8fa4788943934a586134101b58c40cacbd8fb3d83bd140d36db70bf19e82a65e3a87770bcf6a6ab48aa8bbbe
7
+ data.tar.gz: 9333ae8a1cb9bab180035dba641c23a5315783bf3d6bd0e790f0b21eb3da0a8ae149c1264ff4619ef75caaff1ec51bf8fdf4cd7c1f36659552d8cb685b7f4332
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.7] - 2024-02-06
6
+ ### Added
7
+ - Added ability to attach project to command (@bougyman)
8
+ - Added issue pr command (@bougyman)
9
+ - Added lcomment alias to add comments to issues (@bougyman)
10
+
5
11
  ## [0.7.5] - 2024-02-05
6
12
  ### Fixed
7
13
  - Fixed problem when choosing from multiple completed states (@bougyman)
@@ -45,8 +51,9 @@
45
51
  ### Added
46
52
  - Added new changelog management system (changelog-rb) (@bougyman)
47
53
 
48
- [Unreleased]: https://github.com/rubyists/linear-cli/compare/0.7.5...HEAD
49
- [0.7.5]: https://github.com/rubyists/linear-cli/compare/v0.7.3...0.7.5
54
+ [Unreleased]: https://github.com/rubyists/linear-cli/compare/0.7.7...HEAD
55
+ [0.7.7]: https://github.com/rubyists/linear-cli/compare/v0.7.5...0.7.7
56
+ [0.7.5]: https://github.com/rubyists/linear-cli/compare/v0.7.3...v0.7.5
50
57
  [0.7.3]: https://github.com/rubyists/linear-cli/compare/v0.7.2...v0.7.3
51
58
  [0.7.2]: https://github.com/rubyists/linear-cli/compare/v0.7.1...v0.7.2
52
59
  [0.7.1]: https://github.com/rubyists/linear-cli/compare/v0.7.0...v0.7.1
data/Readme.adoc CHANGED
@@ -133,8 +133,13 @@ at a time. You can also use the 'u' alias for 'update', and as always, the 'i' a
133
133
 
134
134
  [source,sh]
135
135
  ----
136
- $ lc issue update --comment "Here is a comment" CRY-1234
136
+ $ lc issue update --comment "Here is a comment" CRY-1234 <1>
137
+ $ lc issue update --comment - CRY-14 CRY-15 <2>
138
+ $ lcomment CRY-1234 CRY-3 <3>
137
139
  ----
140
+ <1> This will use the provided comment
141
+ <2> This will prompt for a comment (use '-' to prompt)
142
+ <3> This will always prompt you for a comment ('lcomment' is an alias for 'lc issue update --comment -')
138
143
 
139
144
  ===== Close one or many issues
140
145
 
@@ -152,4 +157,5 @@ Some command aliases are available to make things easier to type.
152
157
  $ lcls
153
158
  $ lcreate --description "This is a new issue" --labels Bug,Feature --team CRY
154
159
  $ lclose --reason "This issue sucks" CRY-1234 CRY-456
160
+ $ lcancel --reason "These should never have been here" --trash CRY-1234 CRY-456
155
161
  ----
@@ -0,0 +1,4 @@
1
+ type: Added
2
+ title: >
3
+ Added ability to attach project to command
4
+ author: bougyman
@@ -0,0 +1,4 @@
1
+ type: Added
2
+ title: >
3
+ Added issue pr command
4
+ author: bougyman
@@ -0,0 +1,4 @@
1
+ type: Added
2
+ title: >
3
+ Added lcomment alias to add comments to issues
4
+ author: bougyman
@@ -0,0 +1 @@
1
+ date: 2024-02-06
data/exe/lc CHANGED
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- exec File.join(__dir__, 'lc.sh'), *ARGV
4
+ require 'pathname'
5
+ script_dir = Pathname(__dir__).join('scripts')
6
+ basename = File.basename(__FILE__)
7
+ script = script_dir.join(basename).exist? ? script_dir.join(basename) : script_dir.join("#{basename}.sh")
8
+ exec script.to_s, *ARGV
data/exe/lclose ADDED
@@ -0,0 +1 @@
1
+ lc
data/exe/lcls ADDED
@@ -0,0 +1 @@
1
+ lc
data/exe/lcomment ADDED
@@ -0,0 +1 @@
1
+ lc
data/exe/lcreate ADDED
@@ -0,0 +1 @@
1
+ lc
@@ -9,9 +9,10 @@ then
9
9
  linear-cli "$@" 2>&1|sed 's/linear-cli/lc/g'
10
10
  exit 0
11
11
  fi
12
- if ! linear-cli "$@"
13
- then
14
- printf "lc: linear-cli failed\n" >&2
12
+ linear-cli "$@"
13
+ result=$?
14
+ if [ $result -gt 1 ]; then
15
+ printf "lc: linear-cli failed %s\n" $result >&2
15
16
  lc "$@" --help 2>&1
16
17
  exit 1
17
18
  fi
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec lc issue list "$@"
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec linear-cli issue update --comment - "$@"
data/lib/linear/api.rb CHANGED
@@ -42,7 +42,7 @@ module Rubyists
42
42
 
43
43
  def query(query)
44
44
  call format('{ "query": %s }', query.to_s.to_json)
45
- rescue StandardError => e
45
+ rescue SmellsBad => e
46
46
  logger.error('Error in query', query:, error: e)
47
47
  raise e unless Rubyists::Linear.verbosity > 2
48
48
 
@@ -9,8 +9,13 @@ module Rubyists
9
9
  # Global options for all commands
10
10
  mod.instance_eval do
11
11
  option :output, type: :string, default: 'text', values: %w[text json], desc: 'Output format'
12
- option :debug, type: :integer, default: 0, desc: 'Debug level'
12
+ option :debug,
13
+ type: :integer,
14
+ aliases: ['-D'],
15
+ default: 0,
16
+ desc: 'Debug level (greater than 0 to see backtraces)'
13
17
  end
18
+
14
19
  Caller.class_eval do
15
20
  # Wraps the :call method so the debug option is honored, and we can trace the call
16
21
  # as well as handle any exceptions that are raised
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # This is where all the _for methods live
4
+ require_relative 'what_for'
5
+
3
6
  module Rubyists
4
7
  module Linear
5
8
  module CLI
6
9
  # The SubCommands module should be included in all commands with subcommands
7
10
  module SubCommands
11
+ include CLI::WhatFor
12
+
8
13
  def self.included(mod)
9
14
  mod.instance_eval do
10
15
  def const_added(const)
@@ -41,73 +46,6 @@ module Rubyists
41
46
  @prompt ||= CLI.prompt
42
47
  end
43
48
 
44
- def team_for(key = nil)
45
- return Rubyists::Linear::Team.find(key) if key
46
-
47
- ask_for_team
48
- end
49
-
50
- def reason_for(reason = nil, four: nil)
51
- return reason if reason
52
-
53
- question = four ? "Reason for #{four}:" : 'Reason:'
54
- prompt.ask(question)
55
- end
56
-
57
- def cancelled_state_for(thingy)
58
- states = thingy.cancelled_states
59
- return states.first if states.size == 1
60
-
61
- selection = prompt.select('Choose a cancelled state', states.to_h { |s| [s.name, s.id] })
62
- Rubyists::Linear::WorkflowState.find selection
63
- end
64
-
65
- def completed_state_for(thingy)
66
- states = thingy.completed_states
67
- return states.first if states.size == 1
68
-
69
- selection = prompt.select('Choose a completed state', states.to_h { |s| [s.name, s.id] })
70
- Rubyists::Linear::WorkflowState.find selection
71
- end
72
-
73
- def description_for(description = nil)
74
- return description if description
75
-
76
- prompt.multiline('Description:').map(&:chomp).join('\\n')
77
- end
78
-
79
- def title_for(title = nil)
80
- return title if title
81
-
82
- prompt.ask('Title:')
83
- end
84
-
85
- def labels_for(team, labels = nil)
86
- return Rubyists::Linear::Label.find_all_by_name(labels.map(&:strip)) if labels
87
-
88
- prompt.on(:keypress) do |event|
89
- prompt.trigger(:keydown) if event.value == 'j'
90
- prompt.trigger(:keyup) if event.value == 'k'
91
- end
92
- prompt.multi_select('Labels:', team.labels.to_h { |t| [t.name, t] })
93
- end
94
-
95
- def cut_branch!(branch_name)
96
- if current_branch != default_branch
97
- 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
98
- end
99
- git.branch(branch_name)
100
- end
101
-
102
- def branch_for(branch_name)
103
- logger.trace('Looking for branch', branch_name:)
104
- existing = git.branches[branch_name]
105
- return cut_branch!(branch_name) unless existing
106
-
107
- logger.trace('Branch found', branch: existing&.name)
108
- existing
109
- end
110
-
111
49
  def current_branch
112
50
  git.current_branch
113
51
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rubyists
4
4
  module Linear
5
- VERSION = '0.7.6'
5
+ VERSION = '0.7.7'
6
6
  end
7
7
  end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyists
4
+ module Linear
5
+ module CLI
6
+ # Module for the _for methods
7
+ module WhatFor
8
+ def editor_for(prefix)
9
+ file = Tempfile.open(prefix, Rubyists::Linear.tmpdir)
10
+ TTY::Editor.open(file.path)
11
+ file.close
12
+ File.readlines(file.path).map(&:chomp).join('\\n')
13
+ ensure
14
+ file&.close
15
+ end
16
+
17
+ def comment_for(issue, comment)
18
+ return comment unless comment.nil? || comment == '-'
19
+
20
+ comment = prompt.ask("Comment for #{issue.identifier} - #{issue.title} (- to open an editor)", default: '-')
21
+ return comment unless comment == '-'
22
+
23
+ editor_for %w[comment .md]
24
+ end
25
+
26
+ def team_for(key = nil)
27
+ return Rubyists::Linear::Team.find(key) if key
28
+
29
+ ask_for_team
30
+ end
31
+
32
+ def reason_for(reason = nil, four: nil)
33
+ return reason if reason && reason != '-'
34
+
35
+ question = four ? "Reason for #{TTY::Markdown.parse(four)}" : 'Reason'
36
+ answer = prompt.ask("#{question} (- to open an editor):", default: '-')
37
+ return answer unless answer == '-'
38
+
39
+ editor_for %w[reason .md]
40
+ end
41
+
42
+ def cancelled_state_for(thingy)
43
+ states = thingy.cancelled_states
44
+ return states.first if states.size == 1
45
+
46
+ selection = prompt.select('Choose a cancelled state', states.to_h { |s| [s.name, s.id] })
47
+ Rubyists::Linear::WorkflowState.find selection
48
+ end
49
+
50
+ def completed_state_for(thingy)
51
+ states = thingy.completed_states
52
+ return states.first if states.size == 1
53
+
54
+ selection = prompt.select('Choose a completed state', states.to_h { |s| [s.name, s.id] })
55
+ Rubyists::Linear::WorkflowState.find selection
56
+ end
57
+
58
+ def description_for(description = nil)
59
+ return description if description
60
+
61
+ prompt.multiline('Description:').map(&:chomp).join('\\n')
62
+ end
63
+
64
+ def title_for(title = nil)
65
+ return title if title
66
+
67
+ prompt.ask('Title:')
68
+ end
69
+
70
+ def ask_for_projects(projects, search: true)
71
+ prompt.warn("No project found matching #{search}.") if search
72
+ return projects.first if projects.size == 1
73
+
74
+ prompt.select('Project:', projects.to_h { |p| [p.name, p] })
75
+ end
76
+
77
+ def project_scores(projects, search_term)
78
+ projects.select { |p| p.match_score?(search_term).positive? }.sort_by { |p| p.match_score?(search_term) }
79
+ end
80
+
81
+ def project_for(team, project = nil)
82
+ projects = team.projects
83
+ return nil if projects.empty?
84
+
85
+ possibles = project_scores(projects, project)
86
+ return ask_for_projects(projects, search: project) if possibles.empty?
87
+
88
+ first = possibles.first
89
+ return first if first.match_score?(project) == 100
90
+
91
+ selections = possibles + (projects - possibles)
92
+ prompt.select('Project:', selections.to_h { |p| [p.name, p] }) if possibles.size.positive?
93
+ end
94
+
95
+ def pr_title_for(issue)
96
+ proposed = [pr_type_for(issue)]
97
+ proposed_scope = pr_scope_for(issue.title)
98
+ proposed << "(#{proposed_scope})" if proposed_scope
99
+ summary = issue.title.sub(/(?:#{ALLOWED_PR_TYPES})(\([^)]+\))? /, '')
100
+ proposed << ": #{issue.identifier} - #{summary}"
101
+ prompt.ask("Title for PR for #{issue.identifier} - #{summary}", default: proposed.join)
102
+ end
103
+
104
+ def pr_description_for(issue)
105
+ tmpfile = Tempfile.new([issue.identifier, '.md'], Rubyists::Linear.tmpdir)
106
+ # TODO: Look up templates
107
+ proposed = "# Context\n\n#{issue.description}\n\n## Issue\n\n#{issue.identifier}\n\n# Solution\n\n# Testing\n\n# Notes\n\n" # rubocop:disable Layout/LineLength
108
+ tmpfile.write(proposed) && tmpfile.close
109
+ desc = TTY::Editor.open(tmpfile.path)
110
+ return tmpfile if desc
111
+
112
+ File.open(tmpfile.path, 'w+') do |file|
113
+ file.puts prompt.ask("Description for PR for #{issue.identifier} - #{issue.title}", default: proposed)
114
+ end
115
+ tmpfile
116
+ end
117
+
118
+ def pr_type_for(issue)
119
+ proposed_type = issue.title.match(/^(#{ALLOWED_PR_TYPES})/i)
120
+ return proposed_type[1].downcase if proposed_type
121
+
122
+ prompt.select('What type of PR is this?', %w[fix feature chore refactor test docs style ci perf security])
123
+ end
124
+
125
+ def pr_scope_for(title)
126
+ proposed_scope = title.match(/^\w+\(([^\)]+)\)/)
127
+ return proposed_scope[1].downcase if proposed_scope
128
+
129
+ scope = prompt.ask('What is the scope of this PR?', default: 'none')
130
+ return nil if scope.empty? && scope == 'none'
131
+
132
+ scope
133
+ end
134
+
135
+ def labels_for(team, labels = nil)
136
+ return Rubyists::Linear::Label.find_all_by_name(labels.map(&:strip)) if labels
137
+
138
+ prompt.multi_select('Labels:', team.labels.to_h { |t| [t.name, t] })
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
data/lib/linear/cli.rb CHANGED
@@ -16,7 +16,14 @@ module Rubyists
16
16
  extend Dry::CLI::Registry
17
17
 
18
18
  def self.prompt
19
- @prompt ||= TTY::Prompt.new
19
+ return @prompt if @prompt
20
+
21
+ @prompt = TTY::Prompt.new
22
+ @prompt.on(:keypress) do |event|
23
+ @prompt.trigger(:keydown) if event.value == 'j'
24
+ @prompt.trigger(:keyup) if event.value == 'k'
25
+ end
26
+ @prompt
20
27
  end
21
28
 
22
29
  def self.register_sub!(command, sub_file, klass)
@@ -21,6 +21,7 @@ module Rubyists
21
21
  option :description, type: :string, aliases: ['-d'], desc: 'Issue Description'
22
22
  option :team, type: :string, aliases: ['-T'], desc: 'Team Identifier'
23
23
  option :labels, type: :array, aliases: ['-l'], desc: 'Labels for the issue (Comma separated list)'
24
+ option :project, type: :string, aliases: ['-p'], desc: 'Project Identifier'
24
25
  option :develop, type: :boolean, aliases: ['-D', '--dev'], desc: 'Start development after creating the issue'
25
26
 
26
27
  def call(**options)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'semantic_logger'
4
+ require 'git'
5
+ require_relative '../issue'
6
+
7
+ module Rubyists
8
+ # Namespace for Linear
9
+ module Linear
10
+ M :issue, :user, :label
11
+ # Namespace for CLI
12
+ module CLI
13
+ module Issue
14
+ Pr = Class.new Dry::CLI::Command
15
+ # The Develop class is a Dry::CLI::Command to start/update development status of an issue
16
+ class Pr
17
+ include SemanticLogger::Loggable
18
+ include Rubyists::Linear::CLI::CommonOptions
19
+ include Rubyists::Linear::CLI::Issue # for #gimme_da_issue! and other Issue methods
20
+ desc 'Create a PR for an issue and push it to the remote'
21
+ argument :issue_id, required: true, desc: 'The Issue (i.e. CRY-1)'
22
+ option :title, required: false, desc: 'The title of the PR'
23
+ option :description, required: false, desc: 'The description of the PR'
24
+
25
+ def call(issue_id:, **options)
26
+ logger.debug('Creating PR for issue issue', options:)
27
+ issue = gimme_da_issue!(issue_id, me: Rubyists::Linear::User.me)
28
+ branch_name = issue.branchName
29
+ branch = branch_for(branch_name)
30
+ branch.checkout
31
+ prompt.ok "Checked out branch #{branch_name}"
32
+ issue_pr(issue, **options)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -19,11 +19,11 @@ module Rubyists
19
19
  include Rubyists::Linear::CLI::Issue # for #gimme_da_issue! and other Issue methods
20
20
  desc 'Update an issue'
21
21
  argument :issue_ids, type: :array, required: true, desc: 'Issue IDs (i.e. CRY-1)'
22
- option :comment, type: :string, aliases: ['-m'], desc: 'Comment to add to the issue'
23
- option :pr, type: :boolean, aliases: ['--pull-request'], default: false, desc: 'Create a pull request'
22
+ option :comment, type: :string, aliases: ['-m'], desc: 'Comment to add to the issue. - open an editor'
23
+ option :project, type: :string, aliases: ['-p'], desc: 'Project to move the issue to. - select from a list'
24
24
  option :cancel, type: :boolean, default: false, desc: 'Cancel the issue'
25
25
  option :close, type: :boolean, default: false, desc: 'Close the issue'
26
- option :reason, type: :string, aliases: ['--butwhy'], desc: 'Reason for closing the issue'
26
+ option :reason, type: :string, aliases: ['--butwhy'], desc: 'Reason for closing the issue. - open an editor'
27
27
  option :trash,
28
28
  type: :boolean,
29
29
  default: false,
@@ -31,7 +31,8 @@ module Rubyists
31
31
 
32
32
  example [
33
33
  '--comment "This is a comment" CRY-1 CRY2 # Add a comment to multiple issues',
34
- '--pr CRY-10 # Create a pull request for the issue',
34
+ '--comment - CRY-1 CRY2 # Add a comment to multiple issues, open an editor',
35
+ '--project "Manhattan" CRY-3 CRY-4 # Move tickets to a different project',
35
36
  '--close CRY-2 # Close an issue. Will be prompted for a reason',
36
37
  '--close --reason "Done" CRY-1 CRY-2 # Close multiple issues with a reason',
37
38
  '--cancel --trash --reason "Garbage" CRY-2 # Cancel an issue, and throw it in the trash'
@@ -4,6 +4,8 @@
4
4
  # as well as other helpers which are used in multiple commands and subcommands
5
5
  # This is also where the #prompt method is defined, which is used to display messages to the user and get input
6
6
  require_relative '../cli/sub_commands'
7
+ require 'tty-editor'
8
+ require 'git'
7
9
 
8
10
  module Rubyists
9
11
  module Linear
@@ -15,6 +17,7 @@ module Rubyists
15
17
  include CLI::SubCommands
16
18
 
17
19
  DESCRIPTION = 'Manage issues'
20
+ ALLOWED_PR_TYPES = 'bug|fix|sec(urity)|feat(ure)|chore|refactor|test|docs|style|ci|perf'
18
21
 
19
22
  # Aliases for Issue commands
20
23
  ALIASES = {
@@ -22,20 +25,21 @@ module Rubyists
22
25
  develop: %w[d dev], # aliases for the develop command
23
26
  list: %w[l ls], # aliases for the list command
24
27
  update: %w[u], # aliases for the close command
28
+ pr: %w[pull-request], # aliases for the pr command
25
29
  issue: %w[i issues] # aliases for the main issue command itself
26
30
  }.freeze
27
31
 
28
32
  def issue_comment(issue, comment)
29
- issue.add_comment(comment)
30
- prompt.ok("Comment added to #{issue.identifier}")
33
+ issue.add_comment comment_for(issue, comment)
34
+ prompt.ok "Comment added to #{issue.identifier}"
31
35
  end
32
36
 
33
37
  def cancel_issue(issue, **options)
34
38
  reason = reason_for(options[:reason], four: "cancelling #{issue.identifier} - #{issue.title}")
35
- issue_comment(issue, reason)
39
+ issue_comment issue, reason
36
40
  cancel_state = cancel_state_for(issue)
37
- issue.close!(state: cancel_state, trash: options[:trash])
38
- prompt.ok("#{issue.identifier} was cancelled")
41
+ issue.close! state: cancel_state, trash: options[:trash]
42
+ prompt.ok "#{issue.identifier} was cancelled"
39
43
  end
40
44
 
41
45
  def close_issue(issue, **options)
@@ -43,46 +47,61 @@ module Rubyists
43
47
  doing = cancelled ? 'cancelling' : 'closing'
44
48
  done = cancelled ? 'cancelled' : 'closed'
45
49
  workflow_state = cancelled ? cancelled_state_for(issue) : completed_state_for(issue)
46
- reason = reason_for(options[:reason], four: "#{doing} #{issue.identifier} - #{issue.title}")
47
- issue_comment(issue, reason)
48
- issue.close!(state: workflow_state, trash: options[:trash])
49
- prompt.ok("#{issue.identifier} was #{done}")
50
+ reason = reason_for(options[:reason], four: "#{doing} *#{issue.identifier} - #{issue.title}*")
51
+ issue_comment issue, reason
52
+ issue.close! state: workflow_state, trash: options[:trash]
53
+ prompt.ok "#{issue.identifier} was #{done}"
50
54
  end
51
55
 
52
- def issue_pr(issue)
53
- issue.create_pr!
54
- prompt.ok("Pull request created for #{issue.identifier}")
56
+ def create_pr!(title:, body:)
57
+ return `gh pr create -a @me --title "#{title}" --body-file "#{body.path}"` if body.respond_to?(:path)
58
+
59
+ `gh pr create -a @me --title "#{title}" --body "#{body}"`
60
+ end
61
+
62
+ def issue_pr(issue, **options)
63
+ title = options[:title] || pr_title_for(issue)
64
+ body = options[:description] || pr_description_for(issue)
65
+ create_pr!(title:, body:)
66
+ end
67
+
68
+ def attach_project(issue, project_search)
69
+ project = project_for(issue.team, project_search)
70
+ issue.attach_to_project project
71
+ prompt.ok "#{issue.identifier} was attached to #{project.name}"
55
72
  end
56
73
 
57
74
  def update_issue(issue, **options)
58
75
  issue_comment(issue, options[:comment]) if options[:comment]
59
76
  return close_issue(issue, **options) if options[:close]
60
77
  return issue_pr(issue) if options[:pr]
78
+ return attach_project(issue, options[:project]) if options[:project]
61
79
  return if options[:comment]
62
80
 
63
- prompt.warn('No action taken, no options specified')
64
- prompt.ok('Issue was not updated')
81
+ prompt.warn 'No action taken, no options specified'
82
+ prompt.ok 'Issue was not updated'
65
83
  end
66
84
 
67
85
  def make_da_issue!(**options)
68
86
  # These *_for methods are defined in Rubyists::Linear::CLI::SubCommands
69
- title = title_for options[:title]
70
- description = description_for options[:description]
71
- team = team_for options[:team]
72
- labels = labels_for team, options[:labels]
73
- Rubyists::Linear::Issue.create(title:, description:, team:, labels:)
87
+ title = title_for(options[:title])
88
+ description = description_for(options[:description])
89
+ team = team_for(options[:team])
90
+ labels = labels_for(team, options[:labels])
91
+ project = project_for(team, options[:project])
92
+ Rubyists::Linear::Issue.create(title:, description:, team:, labels:, project:)
74
93
  end
75
94
 
76
95
  def gimme_da_issue!(issue_id, me: Rubyists::Linear::User.me) # rubocop:disable Naming/MethodParameterName
77
96
  logger.trace('Looking up issue', issue_id:, me:)
78
97
  issue = Rubyists::Linear::Issue.find(issue_id)
79
- if issue.assignee && issue.assignee[:id] == me.id
80
- prompt.say("You are already assigned #{issue_id}")
98
+ if issue.assignee && issue.assignee.id == me.id
99
+ prompt.say "You are already assigned #{issue_id}"
81
100
  return issue
82
101
  end
83
102
 
84
- prompt.say("Assigning issue #{issue_id} to ya")
85
- updated = issue.assign! me
103
+ prompt.say "Assigning issue #{issue_id} to ya"
104
+ updated = issue.assign!(me)
86
105
  logger.trace 'Issue taken', issue: updated
87
106
  updated
88
107
  end
@@ -5,27 +5,55 @@ module Rubyists
5
5
  class BaseModel
6
6
  # Class methods for Linear models.
7
7
  module ClassMethods
8
- def many_to_one(relation, klass)
8
+ def setter!(relation, klass)
9
+ define_method "#{relation}=" do |val|
10
+ hash = val.is_a?(Hash) ? val : val.updated_data
11
+ updated_data[relation] = hash
12
+ instance_variable_set("@#{relation}", Rubyists::Linear.const_get(klass).new(hash))
13
+ end
14
+ end
15
+
16
+ def getter!(relation)
9
17
  define_method relation do
10
18
  return instance_variable_get("@#{relation}") if instance_variable_defined?("@#{relation}")
11
- return unless (val = data[relation])
12
19
 
13
- instance_variable_set("@#{relation}", Rubyists::Linear.const_get(klass).new(val))
20
+ return unless (val = updated_data[relation])
21
+
22
+ send("#{relation}=", val)
14
23
  end
24
+ end
25
+
26
+ def many_to_one(relation, klass = nil)
27
+ klass ||= relation.to_s.camelize.to_sym
28
+ getter! relation
29
+ setter! relation, klass
30
+ end
31
+
32
+ alias one_to_one many_to_one
15
33
 
34
+ def many_setter!(relation, klass)
16
35
  define_method "#{relation}=" do |val|
17
- hash = val.is_a?(Hash) ? val : val.data
18
- updated_data[relation] = hash
19
- instance_variable_set("@#{relation}", Rubyists::Linear.const_get(klass).new(hash))
36
+ vals = if val&.key?(:nodes)
37
+ val[:nodes]
38
+ else
39
+ Array(val)
40
+ end
41
+ updated_data[relation] = vals.map { |v| v.is_a?(Hash) ? v : v.updated_data }
42
+ new_relations = vals.map { |v| v.is_a?(Hash) ? Rubyists::Linear.const_get(klass).new(v) : v }
43
+ instance_variable_set("@#{relation}", new_relations)
20
44
  end
21
45
  end
22
46
 
23
- alias one_to_one many_to_one
47
+ def one_to_many(relation, klass = nil)
48
+ klass ||= relation.to_s.singularize.camelize.to_sym
49
+ getter! relation
50
+ many_setter! relation, klass
51
+ end
24
52
 
25
53
  def find(id_val)
26
54
  camel_name = just_name.camelize :lower
27
- bf = base_fragment
28
- query_data = Api.query(query { __node(camel_name, id: id_val) { ___ bf } })
55
+ ff = full_fragment
56
+ query_data = Api.query(query { __node(camel_name, id: id_val) { ___ ff } })
29
57
  new query_data[camel_name.to_sym]
30
58
  end
31
59
 
@@ -63,6 +91,10 @@ module Rubyists
63
91
  const_get(:Base)
64
92
  end
65
93
 
94
+ def full_fragment
95
+ base_fragment
96
+ end
97
+
66
98
  def basic_filter
67
99
  return const_get(:BASIC_FILTER) if const_defined?(:BASIC_FILTER)
68
100
 
@@ -5,17 +5,15 @@ module Rubyists
5
5
  class BaseModel
6
6
  # Methods for Linear models.
7
7
  module MethodMagic
8
- def self.included(base) # rubocop:disable Metrics/MethodLength
8
+ def self.included(base) # rubocop:disable Metrics/AbcSize
9
9
  base.instance_eval do
10
10
  base.base_fragment.__nodes.each do |node|
11
11
  sym = node.__name.to_sym
12
- define_method node.__name do
13
- updated_data[sym]
14
- end
12
+ define_method(sym) { updated_data[sym] } unless instance_methods.include? sym
13
+ esym = :"#{sym}="
14
+ next if instance_methods.include? esym
15
15
 
16
- define_method "#{node.__name}=" do |value|
17
- updated_data[sym] = value
18
- end
16
+ define_method(esym) { |value| updated_data[sym] = value }
19
17
  end
20
18
  end
21
19
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyists
4
+ # Namespace for Linear
5
+ module Linear
6
+ M :user, :team
7
+ # The Issue class represents a Linear issue.
8
+ class Issue
9
+ # Class methods for Issue
10
+ module ClassMethods
11
+ def base_fragment
12
+ @base_fragment ||= fragment('BaseIssue', 'Issue') do
13
+ ___ Base
14
+ assignee { ___ User.base_fragment }
15
+ team { ___ Team.base_fragment }
16
+ end
17
+ end
18
+
19
+ def full_fragment
20
+ @full_fragment ||= fragment('FullIssue', 'Issue') do
21
+ ___ Base
22
+ assignee { ___ User.full_fragment }
23
+ team { ___ Team.full_fragment }
24
+ end
25
+ end
26
+
27
+ def find_all(*slugs)
28
+ slugs.flatten.map { |slug| find(slug) }
29
+ end
30
+
31
+ def create(title:, description:, team:, project:, labels: [])
32
+ team_id = team.id
33
+ label_ids = labels.map(&:id)
34
+ input = { title:, description:, teamId: team_id }
35
+ input[:labelIds] = label_ids unless label_ids.empty?
36
+ input[:projectId] = project.id if project
37
+ m = mutation { issueCreate(input:) { issue { ___ Issue.base_fragment } } }
38
+ query_data = Api.query(m)
39
+ new query_data.dig(:issueCreate, :issue)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -5,13 +5,15 @@ require 'gqli'
5
5
  module Rubyists
6
6
  # Namespace for Linear
7
7
  module Linear
8
- M :base_model, :user
8
+ M :base_model
9
9
  Issue = Class.new(BaseModel)
10
+ M 'issue/class_methods'
10
11
  # The Issue class represents a Linear issue.
11
- class Issue # rubocop:disable Metrics/ClassLength
12
+ class Issue
12
13
  include SemanticLogger::Loggable
13
- one_to_one :assignee, :User
14
- one_to_one :team, :Team
14
+ extend ClassMethods
15
+ many_to_one :assignee, :User
16
+ many_to_one :team, :Team
15
17
 
16
18
  BASIC_FILTER = { completedAt: { null: true } }.freeze
17
19
 
@@ -25,38 +27,6 @@ module Rubyists
25
27
  updatedAt
26
28
  end
27
29
 
28
- class << self
29
- def base_fragment
30
- @base_fragment ||= fragment('IssueWithTeams', 'Issue') do
31
- ___ Base
32
- assignee { ___ User.base_fragment }
33
- team { ___ Team.base_fragment }
34
- end
35
- end
36
-
37
- def find(slug)
38
- q = query { issue(id: slug) { ___ Issue.base_fragment } }
39
- data = Api.query(q)
40
- raise NotFoundError, "Issue not found: #{slug}" if data.nil?
41
-
42
- new(data[:issue])
43
- end
44
-
45
- def find_all(*slugs)
46
- slugs.flatten.map { |slug| find(slug) }
47
- end
48
-
49
- def create(title:, description:, team:, labels: [])
50
- team_id = team.id
51
- label_ids = labels.map(&:id)
52
- input = { title:, description:, teamId: team_id }
53
- input[:labelIds] = label_ids unless label_ids.empty?
54
- m = mutation { issueCreate(input:) { issue { ___ Issue.base_fragment } } }
55
- query_data = Api.query(m)
56
- new query_data.dig(:issueCreate, :issue)
57
- end
58
- end
59
-
60
30
  def comment_fragment
61
31
  @comment_fragment ||= fragment('Comment', 'Comment') do
62
32
  id
@@ -65,6 +35,21 @@ module Rubyists
65
35
  end
66
36
  end
67
37
 
38
+ def update!(input)
39
+ id_for_this = identifier
40
+ m = mutation { issueUpdate(id: id_for_this, input:) { issue { ___ Issue.full_fragment } } }
41
+ query_data = Api.query(m)
42
+ updated = query_data.dig(:issueUpdate, :issue)
43
+ raise SmellsBad, "Unknown response for issue update: #{data} (should have :issueUpdate key)" if updated.nil?
44
+
45
+ @data = @updated_data = updated
46
+ self
47
+ end
48
+
49
+ def attach_to_project(project)
50
+ update!({ projectId: project.id })
51
+ end
52
+
68
53
  # Reference for this mutation:
69
54
  # https://studio.apollographql.com/public/Linear-API/variant/current/schema/reference/inputs/CommentCreateInput
70
55
  def add_comment(comment)
@@ -81,7 +66,7 @@ module Rubyists
81
66
  id_for_this = identifier
82
67
  input = { stateId: close_state.id }
83
68
  input[:trash] = true if trash
84
- mutation { issueUpdate(id: id_for_this, input:) { issue { ___ Issue.base_fragment } } }
69
+ mutation { issueUpdate(id: id_for_this, input:) { issue { ___ Issue.full_fragment } } }
85
70
  end
86
71
 
87
72
  def close!(state: nil, trash: false)
@@ -97,7 +82,7 @@ module Rubyists
97
82
 
98
83
  def assign!(user)
99
84
  this_id = identifier
100
- m = mutation { issueUpdate(id: this_id, input: { assigneeId: user.id }) { issue { ___ Issue.base_fragment } } }
85
+ m = mutation { issueUpdate(id: this_id, input: { assigneeId: user.id }) { issue { ___ Issue.full_fragment } } }
101
86
  query_data = Api.query(m)
102
87
  updated = query_data.dig(:issueUpdate, :issue)
103
88
  raise SmellsBad, "Unknown response for issue update: #{data} (should have :issueUpdate key)" if updated.nil?
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gqli'
4
+
5
+ module Rubyists
6
+ # Namespace for Linear
7
+ module Linear
8
+ M :base_model
9
+ Project = Class.new(BaseModel)
10
+ # The Project class represents a Linear workflow state.
11
+ class Project
12
+ include SemanticLogger::Loggable
13
+
14
+ Base = fragment('BaseProject', 'Project') do
15
+ id
16
+ name
17
+ content
18
+ slugId
19
+ description
20
+ url
21
+ createdAt
22
+ updatedAt
23
+ end
24
+
25
+ def slug
26
+ File.basename(url).sub("-#{slugId}", '')
27
+ end
28
+
29
+ def match_score?(string)
30
+ downed = string.downcase
31
+ return 100 if downed.split.join('-') == slug || downed == name.downcase
32
+ return 75 if name.include?(string) || slug.include?(downed)
33
+ return 50 if description.downcase.include?(downed)
34
+
35
+ 0
36
+ end
37
+
38
+ def to_s
39
+ format('%<name>-12s %<url>s', name:, url:)
40
+ end
41
+
42
+ def inspection
43
+ format('name: "%<name>s" type: "%<url>s"', name:, url:)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -5,11 +5,12 @@ require 'gqli'
5
5
  module Rubyists
6
6
  # Namespace for Linear
7
7
  module Linear
8
- M :base_model, :issue, :user, :workflow_state
8
+ M :base_model, :issue, :project, :workflow_state, :user
9
9
  Team = Class.new(BaseModel)
10
10
  # The Issue class represents a Linear issue.
11
11
  class Team
12
12
  include SemanticLogger::Loggable
13
+ one_to_many :projects
13
14
 
14
15
  # TODO: Make this configurable
15
16
  BaseFilter = { # rubocop:disable Naming/ConstantName
@@ -29,15 +30,11 @@ module Rubyists
29
30
  updatedAt
30
31
  end
31
32
 
32
- def self.find(key)
33
- q = query do
34
- team(id: key) { ___ Base }
33
+ def self.full_fragment
34
+ @full_fragment ||= fragment('WholeTeam', 'Team') do
35
+ ___ Base
36
+ projects { nodes { ___ Project.base_fragment } }
35
37
  end
36
- data = Api.query(q)
37
- hash = data[:team]
38
- raise NotFoundError, "Team not found: #{key}" unless hash
39
-
40
- new hash
41
38
  end
42
39
 
43
40
  def self.mine
data/linear-cli.gemspec CHANGED
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
29
29
  end
30
30
  end
31
31
  spec.bindir = 'exe'
32
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }.reject { |f| f.end_with?('.sh') }
33
33
  spec.require_paths = ['lib']
34
34
 
35
35
  # Uncomment to register a new dependency of your gem
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: linear-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.6
4
+ version: 0.7.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tj (bougyman) Vanderpoel
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-02-05 00:00:00.000000000 Z
11
+ date: 2024-02-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -197,13 +197,10 @@ email:
197
197
  - tj@rubyists.com
198
198
  executables:
199
199
  - lc
200
- - lc.sh
201
200
  - lclose
202
- - lclose.sh
203
201
  - lcls
204
- - lcls.sh
202
+ - lcomment
205
203
  - lcreate
206
- - lcreate.sh
207
204
  - linear-cli
208
205
  extensions: []
209
206
  extra_rdoc_files: []
@@ -233,17 +230,23 @@ files:
233
230
  - changelog/0.7.3/tag.yml
234
231
  - changelog/0.7.5/fixed_problem_when_choosing_from_multiple_completed_states.yml
235
232
  - changelog/0.7.5/tag.yml
233
+ - changelog/0.7.7/added_ability_to_attach_project_to_command.yml
234
+ - changelog/0.7.7/added_issue_pr_command.yml
235
+ - changelog/0.7.7/added_lcomment_alias_to_add_comments_to_issues.yml
236
+ - changelog/0.7.7/tag.yml
236
237
  - changelog/unreleased/.gitkeep
237
238
  - cinemas/listings.cinema
238
239
  - exe/lc
239
- - exe/lc.sh
240
240
  - exe/lclose
241
- - exe/lclose.sh
242
241
  - exe/lcls
243
- - exe/lcls.sh
242
+ - exe/lcomment
244
243
  - exe/lcreate
245
- - exe/lcreate.sh
246
244
  - exe/linear-cli
245
+ - exe/scripts/lc.sh
246
+ - exe/scripts/lclose.sh
247
+ - exe/scripts/lcls.sh
248
+ - exe/scripts/lcomment.sh
249
+ - exe/scripts/lcreate.sh
247
250
  - lib/linear.rb
248
251
  - lib/linear/api.rb
249
252
  - lib/linear/cli.rb
@@ -252,10 +255,12 @@ files:
252
255
  - lib/linear/cli/sub_commands.rb
253
256
  - lib/linear/cli/version.rb
254
257
  - lib/linear/cli/watcher.rb
258
+ - lib/linear/cli/what_for.rb
255
259
  - lib/linear/commands/issue.rb
256
260
  - lib/linear/commands/issue/create.rb
257
261
  - lib/linear/commands/issue/develop.rb
258
262
  - lib/linear/commands/issue/list.rb
263
+ - lib/linear/commands/issue/pr.rb
259
264
  - lib/linear/commands/issue/take.rb
260
265
  - lib/linear/commands/issue/update.rb
261
266
  - lib/linear/commands/team.rb
@@ -267,7 +272,9 @@ files:
267
272
  - lib/linear/models/base_model/class_methods.rb
268
273
  - lib/linear/models/base_model/method_magic.rb
269
274
  - lib/linear/models/issue.rb
275
+ - lib/linear/models/issue/class_methods.rb
270
276
  - lib/linear/models/label.rb
277
+ - lib/linear/models/project.rb
271
278
  - lib/linear/models/team.rb
272
279
  - lib/linear/models/user.rb
273
280
  - lib/linear/models/workflow_state.rb
data/exe/lclose DELETED
@@ -1,4 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- exec File.join(__dir__, 'lclose.sh'), *ARGV
data/exe/lcls DELETED
@@ -1,4 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- exec File.join(__dir__, 'lcls.sh'), *ARGV
data/exe/lcls.sh DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env bash
2
- exec linear-cli issue list "$@"
data/exe/lcreate DELETED
@@ -1,4 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- exec File.join(__dir__, 'lcreate.sh'), *ARGV
File without changes
File without changes