Skip to content

Commit f5bd116

Browse files
committed
Adds issue update command, and the lclose alias
1 parent 75a34ad commit f5bd116

10 files changed

Lines changed: 255 additions & 31 deletions

File tree

exe/lclose

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
exec File.join(__dir__, 'lclose.sh'), *ARGV

exe/lclose.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 "$@"

lib/linear/cli/sub_commands.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ def team_for(key = nil)
4747
ask_for_team
4848
end
4949

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+
5064
def description_for(description = nil)
5165
return description if description
5266

lib/linear/commands/issue.rb

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,40 @@ module Issue
1212
include CLI::SubCommands
1313
# Aliases for Issue commands
1414
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
2020
}.freeze
2121

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+
2249
def make_da_issue!(**options)
2350
# These *_for methods are defined in Rubyists::Linear::CLI::SubCommands
2451
title = title_for options[:title]
@@ -28,7 +55,7 @@ def make_da_issue!(**options)
2855
Rubyists::Linear::Issue.create(title:, description:, team:, labels:)
2956
end
3057

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
3259
logger.trace('Looking up issue', issue_id:, me:)
3360
issue = Rubyists::Linear::Issue.find(issue_id)
3461
if issue.assignee && issue.assignee[:id] == me.id
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
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

lib/linear/models/base_model.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
require 'sequel/extensions/inflector'
66

77
module Rubyists
8+
# Namespace for Linear
89
module Linear
10+
L :api, :fragments
911
# Module which provides a base model for Linear models.
1012
class BaseModel
1113
extend GQLi::DSL
@@ -32,6 +34,20 @@ def self.included(base) # rubocop:disable Metrics/MethodLength
3234

3335
# Class methods for Linear models.
3436
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+
3551
def const_added(const)
3652
return unless const == :Base
3753

@@ -108,6 +124,10 @@ def changed?
108124
data != updated_data
109125
end
110126

127+
def completed_states
128+
workflow_states.select { |ws| ws.type == 'completed' }
129+
end
130+
111131
def to_h
112132
updated_data
113133
end

lib/linear/models/issue.rb

Lines changed: 82 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,75 +5,136 @@
55
module Rubyists
66
# Namespace for Linear
77
module Linear
8-
L :api
9-
L :fragments
10-
M :base_model
11-
M :user
8+
M :base_model, :user
129
Issue = Class.new(BaseModel)
1310
# The Issue class represents a Linear issue.
14-
class Issue
11+
class Issue # rubocop:disable Metrics/ClassLength
1512
include SemanticLogger::Loggable
13+
has_one :assignee, :User
14+
has_one :team, :Team
1615

1716
BASIC_FILTER = { completedAt: { null: true } }.freeze
1817

1918
Base = fragment('BaseIssue', 'Issue') do
2019
id
2120
identifier
2221
title
23-
assignee { ___ User::Base }
2422
branchName
2523
description
2624
createdAt
2725
updatedAt
2826
end
2927

3028
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+
3137
def find(slug)
32-
q = query { issue(id: slug) { ___ Base } }
38+
q = query { issue(id: slug) { ___ Issue.base_fragment } }
3339
data = Api.query(q)
3440
raise NotFoundError, "Issue not found: #{slug}" if data.nil?
3541

3642
new(data[:issue])
3743
end
3844

45+
def find_all(*slugs)
46+
slugs.flatten.map { |slug| find(slug) }
47+
end
48+
3949
def create(title:, description:, team:, labels: [])
4050
team_id = team.id
4151
label_ids = labels.map(&:id)
4252
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)
4757
end
4858
end
4959

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)
5181
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)
55103
raise SmellsBad, "Unknown response for issue update: #{data} (should have :issueUpdate key)" if updated.nil?
56104

57-
Issue.new updated
105+
@data = @updated_data = updated
106+
self
107+
end
108+
109+
def workflow_states
110+
@workflow_states ||= team.workflow_states
58111
end
59112

60113
def inspection
61114
format('id: "%<identifier>s" title: "%<title>s"', identifier:, title:)
62115
end
63116

64117
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:)
66119
return basic unless (name = data.dig(:assignee, :name))
67120

68121
format('%<basic>s (%<name>s)', basic:, name:)
69122
end
70123

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+
71135
def full
72136
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)
77138
end
78139

79140
def display(options)

lib/linear/models/label.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ def self.base_fragment # rubocop:disable Metrics/AbcSize
3131
fragment('LabelWithTeams', 'IssueLabel') do
3232
___ Base
3333
parent { ___ Base }
34-
team { ___ Team::Base }
34+
team { ___ Team.base_fragment }
3535
end
3636
end
3737

3838
def self.find_all_by_name(names)
3939
q = query do
4040
issueLabels(filter: { name: { in: names } }) do
41-
edges { node { ___ Base } }
41+
edges { node { ___ base_fragment } }
4242
end
4343
end
4444
data = Api.query(q)

lib/linear/models/team.rb

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
module Rubyists
66
# Namespace for Linear
77
module Linear
8-
L :api, :fragments
9-
M :base_model, :issue, :user
8+
M :base_model, :issue, :user, :workflow_state
109
Team = Class.new(BaseModel)
1110
# The Issue class represents a Linear issue.
1211
class Team
@@ -96,6 +95,26 @@ def members
9695
def display(_options)
9796
printf "%s\n", full
9897
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
99118
end
100119
end
101120
end

0 commit comments

Comments
 (0)