linear-cli 0.5.4 → 0.6.0

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: 3178678b69a93bd4717bb28038fc549ed8cd3724ee1d987eab4be8d486025f04
4
- data.tar.gz: ad68d009c3068e5787ac4b2a24520768954e7a648533c355a04702c3496a7c48
3
+ metadata.gz: bc273e12a3dd9e864894c2484bd08a203531da62873505b33d5706f13349e2c6
4
+ data.tar.gz: ffb38d1c66023c229bc98812fa55e4a58e29342adb46e96f63cf0054560a3964
5
5
  SHA512:
6
- metadata.gz: 5257b5c0e526ac9a51c974382769e9a0602793132f8b0ec7b8f35e7ba1c6e6ab6032b988d4c89e3af9b7959e09a9642e6d66a07f7c9e626444979517a016da0b
7
- data.tar.gz: 49682ab5cf46469c720584fe72887581451a17f66edab356e1643fd391dd6fb441f06dbbfeb7d5decd96d4e76096ee51f11cd64a1c64cc90cfd44832d1d93d7a
6
+ metadata.gz: 2323c788c7095f385320006a7be3b167b7dc9fc731c96a13ea5af48dcdec0dc013f8e6f55c71e9631022ae744a5faecbc34c8bda668c172cfb98d935c1af40a0
7
+ data.tar.gz: c8422224df18d500f92d1d8e0d90cd27f41cc8d0c148943b6f53f6e6269d67aae676f2fd006e9a4210199e5aae572e39e9b3bf46ddce2206f2880d88c3a78fac
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.0] - 2024-02-04
6
+
7
+ ## [0.5.5] - 2024-02-04
8
+ ### Added
9
+ - Added lclose alias and 'issue update' subcommand (@bougyman)
10
+
5
11
  ## [0.5.4] - 2024-02-04
6
12
 
7
13
  ## [0.5.3] - 2024-02-03
@@ -15,6 +21,8 @@
15
21
  ### Added
16
22
  - Added new changelog management system (changelog-rb) (@bougyman)
17
23
 
18
- [Unreleased]: https://github.com/rubyists/linear-cli/compare/0.5.4...HEAD
19
- [0.5.4]: https://github.com/rubyists/linear-cli/compare/v0.5.3...0.5.4
24
+ [Unreleased]: https://github.com/rubyists/linear-cli/compare/0.6.0...HEAD
25
+ [0.6.0]: https://github.com/rubyists/linear-cli/compare/v0.5.5...0.6.0
26
+ [0.5.5]: https://github.com/rubyists/linear-cli/compare/v0.5.4...v0.5.5
27
+ [0.5.4]: https://github.com/rubyists/linear-cli/compare/v0.5.3...v0.5.4
20
28
  [0.5.3]: https://github.com/rubyists/linear-cli/compare/v0.5.2...v0.5.3
data/Readme.adoc CHANGED
@@ -119,3 +119,23 @@ $ lc i dev CRY-1234
119
119
  ----
120
120
 
121
121
  TIP: You may pass the --dev option to the create subcommand to immediately develop the creted issue.
122
+
123
+ ==== Update an issue
124
+
125
+ All of the update options can work on multiple issues, so long as it's not more than 50
126
+ at a time. You can also use the 'u' alias for 'update', and as always, the 'i' alias for 'issue'.
127
+
128
+ ===== Add a comment to one or more issues
129
+
130
+ [source,sh]
131
+ ----
132
+ $ lc issue update --comment "Here is a comment" CRY-1234
133
+ ----
134
+
135
+ ===== Close one or many issues
136
+
137
+ [source,sh]
138
+ ----
139
+ $ lc i u --close --reason "These were closable" CRY-1234 CRY-2
140
+ ----
141
+
@@ -0,0 +1,4 @@
1
+ type: Added
2
+ title: >
3
+ Added lclose alias and 'issue update' subcommand
4
+ author: bougyman
@@ -0,0 +1 @@
1
+ date: 2024-02-04
@@ -0,0 +1 @@
1
+ date: 2024-02-04
data/exe/lclose ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ exec File.join(__dir__, 'lclose.sh'), *ARGV
data/exe/lclose.sh ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ if [[ "$*" =~ "--help" ]]
3
+ then
4
+ printf "This wrapper adds the --close option to the 'issue update' command.\n" >&2
5
+ printf "It is used to close one or many issues. The issues are specified by their ID/slugs.\n" >&2
6
+ printf "For closing multiple issues, you really want to pass --reason so you do not get prompted for each issue.\n\n" >&2
7
+ exec lc issue update --help
8
+ fi
9
+ exec lc issue update --close "$@"
@@ -47,6 +47,20 @@ module Rubyists
47
47
  ask_for_team
48
48
  end
49
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 completed_state_for(thingy)
58
+ states = thingy.completed_states
59
+ return states.first if states.size == 1
60
+
61
+ prompt.select('Choose a completed state', states.to_h { |s| [s.name, s.id] })
62
+ end
63
+
50
64
  def description_for(description = nil)
51
65
  return description if description
52
66
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rubyists
4
4
  module Linear
5
- VERSION = '0.5.4'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end
@@ -0,0 +1,37 @@
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
+ Update = Class.new Dry::CLI::Command
15
+ # The Update class is a Dry::CLI::Command to update an issue
16
+ class Update
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 'Update an issue'
21
+ argument :issue_ids, type: :array, required: true, desc: 'Issue IDs (i.e. ISS-1)'
22
+ option :comment, type: :string, aliases: ['--message'], desc: 'Comment to add to the issue'
23
+ option :pr, type: :boolean, aliases: ['--pull-request'], default: false, desc: 'Create a pull request'
24
+ option :close, type: :boolean, default: false, desc: 'Close the issue'
25
+ option :reason, type: :string, aliases: ['--close-reason'], desc: 'Reason for closing the issue'
26
+
27
+ def call(issue_ids:, **options)
28
+ logger.debug('Updating issues', issue_ids:, options:)
29
+ Rubyists::Linear::Issue.find_all(issue_ids).each do |issue|
30
+ update_issue(issue, **options)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -12,13 +12,40 @@ module Rubyists
12
12
  include CLI::SubCommands
13
13
  # Aliases for Issue commands
14
14
  ALIASES = {
15
- create: %w[c new add], # aliases for the create command
16
- develop: %w[d dev], # aliases for the create command
17
- list: %w[l ls], # aliases for the list command
18
- show: %w[s view v], # aliases for the show command
19
- issue: %w[i issues] # aliases for the main issue command itself
15
+ create: %w[c new add], # aliases for the create command
16
+ develop: %w[d dev], # aliases for the develop command
17
+ list: %w[l ls], # aliases for the list command
18
+ update: %w[u], # aliases for the close command
19
+ issue: %w[i issues] # aliases for the main issue command itself
20
20
  }.freeze
21
21
 
22
+ def issue_comment(issue, comment)
23
+ issue.add_comment(comment)
24
+ prompt.ok("Comment added to #{issue.identifier}")
25
+ end
26
+
27
+ def close_issue(issue, **options)
28
+ reason = reason_for(options[:reason], four: "closing #{issue.identifier} - #{issue.title}")
29
+ issue_comment(issue, reason)
30
+ close_state = completed_state_for(issue)
31
+ issue.close!(state: close_state, trash: options[:trash])
32
+ prompt.ok("#{issue.identifier} was closed")
33
+ end
34
+
35
+ def issue_pr(issue)
36
+ issue.create_pr!
37
+ prompt.ok("Pull request created for #{issue.identifier}")
38
+ end
39
+
40
+ def update_issue(issue, **options)
41
+ issue_comment(issue, options[:comment]) if options[:comment]
42
+ return close_issue(issue, **options) if options[:close]
43
+ return issue_pr(issue) if options[:pr]
44
+
45
+ prompt.warn('No action taken, no options specified')
46
+ prompt.ok('Issue was not updated')
47
+ end
48
+
22
49
  def make_da_issue!(**options)
23
50
  # These *_for methods are defined in Rubyists::Linear::CLI::SubCommands
24
51
  title = title_for options[:title]
@@ -28,7 +55,7 @@ module Rubyists
28
55
  Rubyists::Linear::Issue.create(title:, description:, team:, labels:)
29
56
  end
30
57
 
31
- def gimme_da_issue!(issue_id, me) # rubocop:disable Naming/MethodParameterName
58
+ def gimme_da_issue!(issue_id, me: Rubyists::Linear::User.me) # rubocop:disable Naming/MethodParameterName
32
59
  logger.trace('Looking up issue', issue_id:, me:)
33
60
  issue = Rubyists::Linear::Issue.find(issue_id)
34
61
  if issue.assignee && issue.assignee[:id] == me.id
@@ -5,7 +5,9 @@ require 'semantic_logger'
5
5
  require 'sequel/extensions/inflector'
6
6
 
7
7
  module Rubyists
8
+ # Namespace for Linear
8
9
  module Linear
10
+ L :api, :fragments
9
11
  # Module which provides a base model for Linear models.
10
12
  class BaseModel
11
13
  extend GQLi::DSL
@@ -32,6 +34,20 @@ module Rubyists
32
34
 
33
35
  # Class methods for Linear models.
34
36
  class << self
37
+ def has_one(relation, klass) # rubocop:disable Naming/PredicateName
38
+ define_method relation do
39
+ return instance_variable_get("@#{relation}") if instance_variable_defined?("@#{relation}")
40
+
41
+ instance_variable_set("@#{relation}", Rubyists::Linear.const_get(klass).new(data[relation]))
42
+ end
43
+
44
+ define_method "#{relation}=" do |val|
45
+ hash = val.is_a?(Hash) ? val : val.data
46
+ updated_data[relation] = hash
47
+ instance_variable_set("@#{relation}", Rubyists::Linear.const_get(klass).new(hash))
48
+ end
49
+ end
50
+
35
51
  def const_added(const)
36
52
  return unless const == :Base
37
53
 
@@ -108,6 +124,10 @@ module Rubyists
108
124
  data != updated_data
109
125
  end
110
126
 
127
+ def completed_states
128
+ workflow_states.select { |ws| ws.type == 'completed' }
129
+ end
130
+
111
131
  def to_h
112
132
  updated_data
113
133
  end
@@ -5,14 +5,13 @@ require 'gqli'
5
5
  module Rubyists
6
6
  # Namespace for Linear
7
7
  module Linear
8
- L :api
9
- L :fragments
10
- M :base_model
11
- M :user
8
+ M :base_model, :user
12
9
  Issue = Class.new(BaseModel)
13
10
  # The Issue class represents a Linear issue.
14
- class Issue
11
+ class Issue # rubocop:disable Metrics/ClassLength
15
12
  include SemanticLogger::Loggable
13
+ has_one :assignee, :User
14
+ has_one :team, :Team
16
15
 
17
16
  BASIC_FILTER = { completedAt: { null: true } }.freeze
18
17
 
@@ -20,7 +19,6 @@ module Rubyists
20
19
  id
21
20
  identifier
22
21
  title
23
- assignee { ___ User::Base }
24
22
  branchName
25
23
  description
26
24
  createdAt
@@ -28,33 +26,88 @@ module Rubyists
28
26
  end
29
27
 
30
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
+
31
37
  def find(slug)
32
- q = query { issue(id: slug) { ___ Base } }
38
+ q = query { issue(id: slug) { ___ Issue.base_fragment } }
33
39
  data = Api.query(q)
34
40
  raise NotFoundError, "Issue not found: #{slug}" if data.nil?
35
41
 
36
42
  new(data[:issue])
37
43
  end
38
44
 
45
+ def find_all(*slugs)
46
+ slugs.flatten.map { |slug| find(slug) }
47
+ end
48
+
39
49
  def create(title:, description:, team:, labels: [])
40
50
  team_id = team.id
41
51
  label_ids = labels.map(&:id)
42
52
  input = { title:, description:, teamId: team_id }
43
- input.merge!(labelIds: label_ids) unless label_ids.empty?
44
- m = mutation { issueCreate(input:) { issue { ___ Base } } }
45
- data = Api.query(m)
46
- new(data[:issueCreate][:issue])
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)
47
57
  end
48
58
  end
49
59
 
50
- def assign!(user)
60
+ def comment_fragment
61
+ @comment_fragment ||= fragment('Comment', 'Comment') do
62
+ id
63
+ body
64
+ url
65
+ end
66
+ end
67
+
68
+ # Reference for this mutation:
69
+ # https://studio.apollographql.com/public/Linear-API/variant/current/schema/reference/inputs/CommentCreateInput
70
+ def add_comment(comment)
71
+ id_for_this = identifier
72
+ comment_frag = comment_fragment
73
+ m = mutation { commentCreate(input: { issueId: id_for_this, body: comment }) { comment { ___ comment_frag } } }
74
+
75
+ query_data = Api.query(m)
76
+ query_data.dig(:commentCreate, :comment)
77
+ self
78
+ end
79
+
80
+ def close_mutation(close_state, trash: false)
51
81
  id_for_this = identifier
52
- m = mutation { issueUpdate(id: id_for_this, input: { assigneeId: user.id }) { issue { ___ Base } } }
53
- data = Api.query(m)
54
- updated = data.dig(:issueUpdate, :issue)
82
+ input = { stateId: close_state.id }
83
+ input[:trash] = true if trash
84
+ mutation { issueUpdate(id: id_for_this, input:) { issue { ___ Issue.base_fragment } } }
85
+ end
86
+
87
+ def close!(state: nil, trash: false)
88
+ logger.warn "Using first completed state found: #{completed_states.first}" if state.nil?
89
+ state ||= completed_states.first
90
+ query_data = Api.query close_mutation(state, trash:)
91
+ updated = query_data.dig(:issueUpdate, :issue)
92
+ raise SmellsBad, "Unknown response for issue close: #{data} (should have :issueUpdate key)" if updated.nil?
93
+
94
+ @data = @updated_data = updated
95
+ self
96
+ end
97
+
98
+ def assign!(user)
99
+ this_id = identifier
100
+ m = mutation { issueUpdate(id: this_id, input: { assigneeId: user.id }) { issue { ___ Issue.base_fragment } } }
101
+ query_data = Api.query(m)
102
+ updated = query_data.dig(:issueUpdate, :issue)
55
103
  raise SmellsBad, "Unknown response for issue update: #{data} (should have :issueUpdate key)" if updated.nil?
56
104
 
57
- Issue.new updated
105
+ @data = @updated_data = updated
106
+ self
107
+ end
108
+
109
+ def workflow_states
110
+ @workflow_states ||= team.workflow_states
58
111
  end
59
112
 
60
113
  def inspection
@@ -62,18 +115,26 @@ module Rubyists
62
115
  end
63
116
 
64
117
  def to_s
65
- basic = format('%<id>-12s %<title>s', id: data[:identifier], title: data[:title])
118
+ basic = format('%<id>-12s %<title>s', id: identifier, title:)
66
119
  return basic unless (name = data.dig(:assignee, :name))
67
120
 
68
121
  format('%<basic>s (%<name>s)', basic:, name:)
69
122
  end
70
123
 
124
+ def parsed_description
125
+ return TTY::Markdown.parse(description) if description && !description.empty?
126
+
127
+ TTY::Markdown.parse(['# No Description For this issue??',
128
+ 'Issues really need description',
129
+ "## What's up with that?"].join("\n"))
130
+ rescue StandardError => e
131
+ logger.error 'Error parsing description', e
132
+ "Description was unparsable: #{description}\n"
133
+ end
134
+
71
135
  def full
72
136
  sep = '-' * to_s.length
73
- format("%<to_s>s\n%<sep>s\n%<description>s\n",
74
- sep:,
75
- to_s:,
76
- description: (TTY::Markdown.parse(data[:description]) rescue 'No Description?')) # rubocop:disable Style/RescueModifier
137
+ format("%<to_s>s\n%<sep>s\n%<description>s\n", sep:, to_s:, description: parsed_description)
77
138
  end
78
139
 
79
140
  def display(options)
@@ -31,14 +31,14 @@ module Rubyists
31
31
  fragment('LabelWithTeams', 'IssueLabel') do
32
32
  ___ Base
33
33
  parent { ___ Base }
34
- team { ___ Team::Base }
34
+ team { ___ Team.base_fragment }
35
35
  end
36
36
  end
37
37
 
38
38
  def self.find_all_by_name(names)
39
39
  q = query do
40
40
  issueLabels(filter: { name: { in: names } }) do
41
- edges { node { ___ Base } }
41
+ edges { node { ___ base_fragment } }
42
42
  end
43
43
  end
44
44
  data = Api.query(q)
@@ -5,8 +5,7 @@ require 'gqli'
5
5
  module Rubyists
6
6
  # Namespace for Linear
7
7
  module Linear
8
- L :api, :fragments
9
- M :base_model, :issue, :user
8
+ M :base_model, :issue, :user, :workflow_state
10
9
  Team = Class.new(BaseModel)
11
10
  # The Issue class represents a Linear issue.
12
11
  class Team
@@ -96,6 +95,26 @@ module Rubyists
96
95
  def display(_options)
97
96
  printf "%s\n", full
98
97
  end
98
+
99
+ def workflow_states_query
100
+ team_id = id
101
+ query do
102
+ team(id: team_id) do
103
+ states do
104
+ nodes { ___ WorkflowState.base_fragment }
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ def workflow_states
111
+ return @workflow_states if @workflow_states
112
+
113
+ data = Api.query(workflow_states_query)
114
+ @workflow_states = data.dig(:team, :states, :nodes)&.map do |state|
115
+ WorkflowState.new state
116
+ end
117
+ end
99
118
  end
100
119
  end
101
120
  end
@@ -0,0 +1,33 @@
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
+ WorkflowState = Class.new(BaseModel)
10
+ # The WorkflowState class represents a Linear workflow state.
11
+ class WorkflowState
12
+ include SemanticLogger::Loggable
13
+
14
+ Base = fragment('BaseWorkflowState', 'WorkflowState') do
15
+ id
16
+ name
17
+ position
18
+ type
19
+ description
20
+ createdAt
21
+ updatedAt
22
+ end
23
+
24
+ def to_s
25
+ format('%<name>-12s %<type>s', name:, type:)
26
+ end
27
+
28
+ def inspection
29
+ format('name: "%<name>s" type: "%<type>s"', name:, type:)
30
+ end
31
+ end
32
+ end
33
+ end
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.5.4
4
+ version: 0.6.0
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-04 00:00:00.000000000 Z
11
+ date: 2024-02-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -183,6 +183,8 @@ email:
183
183
  - tj@rubyists.com
184
184
  executables:
185
185
  - lc
186
+ - lclose
187
+ - lclose.sh
186
188
  - lcls
187
189
  - lcls.sh
188
190
  extensions: []
@@ -198,8 +200,13 @@ files:
198
200
  - changelog/0.5.3/changed_default_branch_to_use_upstream_default_branch_name.yml
199
201
  - changelog/0.5.3/tag.yml
200
202
  - changelog/0.5.4/tag.yml
203
+ - changelog/0.5.5/added_lclose_alias_and_issue_update_subcommand.yml
204
+ - changelog/0.5.5/tag.yml
205
+ - changelog/0.6.0/tag.yml
201
206
  - changelog/unreleased/.gitkeep
202
207
  - exe/lc
208
+ - exe/lclose
209
+ - exe/lclose.sh
203
210
  - exe/lcls
204
211
  - exe/lcls.sh
205
212
  - lib/linear.rb
@@ -215,6 +222,7 @@ files:
215
222
  - lib/linear/commands/issue/develop.rb
216
223
  - lib/linear/commands/issue/list.rb
217
224
  - lib/linear/commands/issue/take.rb
225
+ - lib/linear/commands/issue/update.rb
218
226
  - lib/linear/commands/team.rb
219
227
  - lib/linear/commands/team/list.rb
220
228
  - lib/linear/commands/whoami.rb
@@ -225,6 +233,7 @@ files:
225
233
  - lib/linear/models/label.rb
226
234
  - lib/linear/models/team.rb
227
235
  - lib/linear/models/user.rb
236
+ - lib/linear/models/workflow_state.rb
228
237
  - lib/linear/version.rb
229
238
  - linear-cli.gemspec
230
239
  - sig/linear/cli.rbs