linear-cli 0.7.6 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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