soapstone 0.1.1
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 +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +43 -0
- data/Rakefile +20 -0
- data/bin/sgn +6 -0
- data/lib/soapstone/.DS_Store +0 -0
- data/lib/soapstone/config/configuration.rb +31 -0
- data/lib/soapstone/config/load.rb +40 -0
- data/lib/soapstone/config/wizard.rb +40 -0
- data/lib/soapstone/core/ai/anthropic_provider.rb +33 -0
- data/lib/soapstone/core/ai/client.rb +36 -0
- data/lib/soapstone/core/ai/open_ai_provider.rb +39 -0
- data/lib/soapstone/core/ai/prompt_builder.rb +91 -0
- data/lib/soapstone/core/commit_message_generator.rb +20 -0
- data/lib/soapstone/core/context/git_branch.rb +42 -0
- data/lib/soapstone/core/context/linear.rb +57 -0
- data/lib/soapstone/core/fetch_linear_issue.rb +72 -0
- data/lib/soapstone/core/git_command.rb +19 -0
- data/lib/soapstone/core/message_presenter.rb +91 -0
- data/lib/soapstone/operations/commit.rb +85 -0
- data/lib/soapstone/ui/components/box.rb +29 -0
- data/lib/soapstone/ui/components/prompt.rb +17 -0
- data/lib/soapstone/ui/menus/ai_settings.rb +131 -0
- data/lib/soapstone/ui/menus/git_settings.rb +55 -0
- data/lib/soapstone/ui/menus/linear_settings.rb +59 -0
- data/lib/soapstone/ui/prompts/ask.rb +24 -0
- data/lib/soapstone/ui/prompts/select.rb +50 -0
- data/lib/soapstone/ui/prompts/select_answer.rb +10 -0
- data/lib/soapstone/ui/prompts/yes_no.rb +25 -0
- data/lib/soapstone/ui/screens/utils.rb +5 -0
- data/lib/soapstone/ui/screens/welcome.rb +103 -0
- data/lib/soapstone/ui/screens/wizard.rb +40 -0
- data/lib/soapstone/version.rb +5 -0
- data/lib/soapstone.rb +49 -0
- data/sig/soapstone.rbs +4 -0
- data/soapstone-0.1.0.gem +0 -0
- metadata +269 -0
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Soapstone::FetchLinearIssue
|
4
|
+
LINEAR_URL = "https://api.linear.app/graphql"
|
5
|
+
|
6
|
+
def self.call
|
7
|
+
new.call
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@config = Soapstone::Config::Load.call
|
12
|
+
@git_context = Soapstone::Context::GitBranch.call
|
13
|
+
@linear_context = Soapstone::Context::Linear.call(@git_context.current_branch)
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
return unless linear_context.enabled?
|
18
|
+
return unless linear_context.pattern && !linear_context.pattern.empty?
|
19
|
+
return {} if git_context.default_branch?
|
20
|
+
return unless linear_context.matches_pattern?
|
21
|
+
|
22
|
+
response = Faraday.post(LINEAR_URL, payload, headers)
|
23
|
+
body = JSON.parse(response.body)
|
24
|
+
body.dig("data", "issue") || {}
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_reader :config, :git_context, :linear_context
|
30
|
+
|
31
|
+
def headers
|
32
|
+
api_key = config.get("linear", "api_key")
|
33
|
+
|
34
|
+
{
|
35
|
+
"Content-Type" => "application/json",
|
36
|
+
"Authorization" => api_key.to_s.strip
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
def issue_key
|
41
|
+
match = git_context.current_branch.match(compiled_regex)
|
42
|
+
|
43
|
+
if match
|
44
|
+
match[1] || match[0] # Use capture group if available, otherwise full match
|
45
|
+
else
|
46
|
+
raise "No Linear ticket found in branch name '#{git_context.current_branch}' using pattern '#{linear_context.pattern}'"
|
47
|
+
end
|
48
|
+
rescue RegexpError => e
|
49
|
+
raise "Invalid regex pattern '#{linear_context.pattern}': #{e.message}"
|
50
|
+
end
|
51
|
+
|
52
|
+
def compiled_regex
|
53
|
+
@compiled_regex ||= Regexp.new(linear_context.pattern, Regexp::IGNORECASE)
|
54
|
+
end
|
55
|
+
|
56
|
+
def payload
|
57
|
+
{
|
58
|
+
query: <<~GRAPHQL,
|
59
|
+
query Issue($id: String!) {
|
60
|
+
issue(id: $id) {
|
61
|
+
identifier
|
62
|
+
title
|
63
|
+
description
|
64
|
+
priority
|
65
|
+
state { name }
|
66
|
+
}
|
67
|
+
}
|
68
|
+
GRAPHQL
|
69
|
+
variables: {id: issue_key}
|
70
|
+
}.to_json
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Soapstone::GitCommand
|
4
|
+
def self.in_git_repository?
|
5
|
+
system("git rev-parse --git-dir > /dev/null 2>&1")
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.staged_files
|
9
|
+
`git diff --cached --name-status`.strip
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.staged_diff
|
13
|
+
`git diff --cached`.strip
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.current_branch
|
17
|
+
`git rev-parse --abbrev-ref HEAD`.strip
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
class Soapstone::MessagePresenter
|
2
|
+
def self.call(message)
|
3
|
+
new(message).call
|
4
|
+
end
|
5
|
+
|
6
|
+
def initialize(message)
|
7
|
+
@message = message
|
8
|
+
end
|
9
|
+
|
10
|
+
def call
|
11
|
+
message
|
12
|
+
.then(&method(:wrap_message))
|
13
|
+
.then(&method(:format_lists))
|
14
|
+
.then(&method(:enhance_typography))
|
15
|
+
.then(&method(:format_subject_line))
|
16
|
+
end
|
17
|
+
|
18
|
+
def wrap_message(text)
|
19
|
+
subject, body = split_subject_and_body(text)
|
20
|
+
|
21
|
+
if subject.nil? # single line message or no clear body structure
|
22
|
+
Strings.wrap(body, 72)
|
23
|
+
elsif body.strip.empty?
|
24
|
+
subject
|
25
|
+
else
|
26
|
+
"#{subject}\n\n#{wrap_body(body)}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
attr_reader :message
|
33
|
+
|
34
|
+
def split_subject_and_body(text)
|
35
|
+
lines = text.split("\n", -1) # -1 to keep trailing empty strings
|
36
|
+
blank_index = lines.find_index(&:empty?)
|
37
|
+
|
38
|
+
return [nil, text] unless blank_index
|
39
|
+
|
40
|
+
subject = lines[0...blank_index].join("\n")
|
41
|
+
body = lines[(blank_index + 1)..]&.join("\n") || ""
|
42
|
+
|
43
|
+
[subject, body]
|
44
|
+
end
|
45
|
+
|
46
|
+
def wrap_body(body_text)
|
47
|
+
body_text.split("\n").map do |paragraph|
|
48
|
+
if paragraph.match?(/^\s*(\d+\.\s|[-*]\s)/)
|
49
|
+
wrap_list_item(paragraph, 72)
|
50
|
+
else
|
51
|
+
cleaned = paragraph.gsub(/\s+/, " ").strip
|
52
|
+
Strings.wrap(cleaned, 72)
|
53
|
+
end
|
54
|
+
end.join("\n")
|
55
|
+
end
|
56
|
+
|
57
|
+
def wrap_list_item(text, width)
|
58
|
+
marker_match = text.match(/^(\s*)([-*]\s|\d+\.\s)(.*)/) # Match both bullet and numbered list items, even if indented
|
59
|
+
return Strings.wrap(text, width) unless marker_match
|
60
|
+
|
61
|
+
leading_spaces = marker_match[1]
|
62
|
+
marker = marker_match[2]
|
63
|
+
content = marker_match[3]
|
64
|
+
|
65
|
+
total_indent = leading_spaces.length + marker.length
|
66
|
+
indent = " " * total_indent
|
67
|
+
|
68
|
+
wrapped_content = Strings.wrap(content, width - total_indent)
|
69
|
+
lines = wrapped_content.split("\n")
|
70
|
+
|
71
|
+
["#{leading_spaces}#{marker}#{lines.first}", *lines[1..].map { |line| "#{indent}#{line}" }].join("\n")
|
72
|
+
end
|
73
|
+
|
74
|
+
def format_lists(text)
|
75
|
+
text.gsub(/^\* (.+)$/) { "- #{$1}" }
|
76
|
+
end
|
77
|
+
|
78
|
+
def enhance_typography(text)
|
79
|
+
text.gsub("...", "…") # Smart ellipses
|
80
|
+
.gsub("--", "—") # Em dashes
|
81
|
+
.gsub(/"([^"]+)"/, '"\1"') # Smart quotes
|
82
|
+
end
|
83
|
+
|
84
|
+
def format_subject_line(text)
|
85
|
+
lines = text.split("\n")
|
86
|
+
return text if lines.first.nil? || lines.first.empty?
|
87
|
+
|
88
|
+
subject = Strings.truncate(lines.first, 50, trailing: "…").sub(/\.$/, "")
|
89
|
+
[subject, *lines[1..]].join("\n")
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/object/blank"
|
4
|
+
|
5
|
+
class Soapstone::Operations::Commit
|
6
|
+
def self.start
|
7
|
+
new.start
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(output: $stdout)
|
11
|
+
@output = output
|
12
|
+
@message_generator = Soapstone::CommitMessageGenerator.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def start
|
16
|
+
display_header
|
17
|
+
|
18
|
+
ensure_in_git_repository
|
19
|
+
ensure_staged_files
|
20
|
+
check_branch_and_warn
|
21
|
+
|
22
|
+
generate_commit_message
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
attr_reader :message_generator, :output
|
28
|
+
|
29
|
+
def display_header
|
30
|
+
output.puts "🎨 Soapstone - Beautiful Commit Messages"
|
31
|
+
end
|
32
|
+
|
33
|
+
def ensure_in_git_repository
|
34
|
+
return if Soapstone::GitCommand.in_git_repository?
|
35
|
+
|
36
|
+
output.puts "❌ Error: Not in a git repository"
|
37
|
+
exit_with_code(1)
|
38
|
+
end
|
39
|
+
|
40
|
+
def ensure_staged_files
|
41
|
+
return if Soapstone::GitCommand.staged_files.present?
|
42
|
+
|
43
|
+
output.puts "⚠️ No staged changes found."
|
44
|
+
output.puts "Run 'git add <files>' to stage changes before creating a commit message."
|
45
|
+
exit_with_code(1)
|
46
|
+
end
|
47
|
+
|
48
|
+
def check_branch_and_warn
|
49
|
+
git_context = Soapstone::Context::GitBranch.call
|
50
|
+
linear_context = Soapstone::Context::Linear.call(git_context.current_branch)
|
51
|
+
|
52
|
+
return unless linear_context.enabled?
|
53
|
+
|
54
|
+
if git_context.default_branch?
|
55
|
+
warn_default_branch(git_context.default_branch)
|
56
|
+
elsif !linear_context.matches_pattern?
|
57
|
+
warn_non_matching_branch(git_context.current_branch, linear_context.pattern)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def generate_commit_message
|
62
|
+
prompt = Soapstone::AI::PromptBuilder.build_prompt
|
63
|
+
message = message_generator.call(prompt)
|
64
|
+
formatted_message = Soapstone::MessagePresenter.call(message)
|
65
|
+
output.puts "💡 Suggested commit message: \n\n#{formatted_message}"
|
66
|
+
end
|
67
|
+
|
68
|
+
def exit_with_code(code)
|
69
|
+
raise SystemExit, code
|
70
|
+
end
|
71
|
+
|
72
|
+
def warn_default_branch(default_branch)
|
73
|
+
output.puts "⚠️ Warning: You are committing directly to your default branch (#{default_branch})."
|
74
|
+
output.puts " Linear integration will be skipped for this commit."
|
75
|
+
output.puts
|
76
|
+
end
|
77
|
+
|
78
|
+
def warn_non_matching_branch(current_branch, linear_pattern)
|
79
|
+
output.puts "⚠️ Warning: This branch name doesn't match your Linear ticket pattern."
|
80
|
+
output.puts " Branch: #{current_branch}"
|
81
|
+
output.puts " Pattern: #{linear_pattern}"
|
82
|
+
output.puts " Linear integration will be skipped for this commit."
|
83
|
+
output.puts
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
class Soapstone::UI::Components::Box
|
2
|
+
BLANK_LINE = ""
|
3
|
+
LINE_BREAK = "\n"
|
4
|
+
|
5
|
+
def self.call(title, &block)
|
6
|
+
new(title).call(&block)
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(title)
|
10
|
+
@title = title
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(&block)
|
14
|
+
TTY::Box.frame(
|
15
|
+
top: 2,
|
16
|
+
left: 2,
|
17
|
+
width: 80,
|
18
|
+
padding: 2,
|
19
|
+
border: {type: :thick, bottom: true},
|
20
|
+
title: {top_left: " #{title} "}
|
21
|
+
) do
|
22
|
+
yield if block_given?
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
attr_reader :title
|
29
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class Soapstone::UI::Components::Prompt
|
2
|
+
def self.call(options: {})
|
3
|
+
new(options: options).call
|
4
|
+
end
|
5
|
+
|
6
|
+
def initialize(options: {})
|
7
|
+
@options = options
|
8
|
+
end
|
9
|
+
|
10
|
+
def call
|
11
|
+
TTY::Prompt.new(help_color: :dim, enable_color: true, interrupt: :exit, **options)
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
attr_reader :options
|
17
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
class Soapstone::UI::Menus::AISettings
|
2
|
+
PROVIDER_OPTIONS = {
|
3
|
+
"open_ai" => "OpenAI",
|
4
|
+
"anthropic" => "Anthropic"
|
5
|
+
}.freeze
|
6
|
+
|
7
|
+
def self.call(config:)
|
8
|
+
new(config: config).call
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(config:)
|
12
|
+
@config = config
|
13
|
+
@pastel = Pastel.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
Soapstone::UI::Screens::Utils.clear
|
18
|
+
enabled = ask_for_ai_enabled
|
19
|
+
|
20
|
+
return unless enabled
|
21
|
+
|
22
|
+
setup_ai_providers
|
23
|
+
set_default_ai_provider
|
24
|
+
show_success_message
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_reader :pastel, :config
|
30
|
+
|
31
|
+
def ask_for_ai_enabled
|
32
|
+
enabled = Soapstone::UI::Prompts::YesNo.call(
|
33
|
+
question: "Do you want to enable AI?",
|
34
|
+
help: "This will enable AI for the project.",
|
35
|
+
default: config.get(:ai, :enabled) || false
|
36
|
+
)
|
37
|
+
config.set(value: enabled, path: %w[ai enabled])
|
38
|
+
enabled
|
39
|
+
end
|
40
|
+
|
41
|
+
def setup_ai_providers
|
42
|
+
loop do
|
43
|
+
provider = ask_which_ai_provider
|
44
|
+
setup_ai_provider(provider)
|
45
|
+
break unless ask_if_other_ai_providers_enabled
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def set_default_ai_provider
|
50
|
+
return unless config.get(:ai, :enabled)
|
51
|
+
|
52
|
+
if config.get(:ai, :api_keys).one?
|
53
|
+
config.set(value: config.get(:ai, :api_keys).keys.first, path: %w[ai default_provider])
|
54
|
+
else
|
55
|
+
ask_for_default_ai_provider
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def ask_for_default_ai_provider
|
60
|
+
default_provider = Soapstone::UI::Prompts::Select.call(
|
61
|
+
question: "Which AI provider do you want to use by default?",
|
62
|
+
help: "I recommend using Anthropic. You can change this later.",
|
63
|
+
answers: default_provider_options,
|
64
|
+
default: config.get(:ai, :default_provider) || :anthropic
|
65
|
+
)
|
66
|
+
config.set(value: default_provider, path: %w[ai default_provider])
|
67
|
+
end
|
68
|
+
|
69
|
+
def ask_which_ai_provider
|
70
|
+
Soapstone::UI::Prompts::Select.call(
|
71
|
+
question: "Which AI provider do you want to use?",
|
72
|
+
help: "This is the AI provider you want to use.",
|
73
|
+
answers: [
|
74
|
+
Soapstone::UI::Prompts::SelectAnswer.new(name: "OpenAI", value: :open_ai),
|
75
|
+
Soapstone::UI::Prompts::SelectAnswer.new(name: "Anthropic", value: :anthropic)
|
76
|
+
],
|
77
|
+
default: config.get(:ai, :provider) || :anthropic
|
78
|
+
)
|
79
|
+
end
|
80
|
+
|
81
|
+
def setup_ai_provider(provider)
|
82
|
+
case provider
|
83
|
+
when :open_ai
|
84
|
+
ask_for_openai_api_key
|
85
|
+
when :anthropic
|
86
|
+
ask_for_anthropic_api_key
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def ask_for_openai_api_key
|
91
|
+
api_key = Soapstone::UI::Prompts::Ask.call(
|
92
|
+
question: "What is your OpenAI API key?",
|
93
|
+
help: "This is the API key you need to use to authenticate with OpenAI.",
|
94
|
+
default: config.get(:ai, :api_keys, :open_ai) || ""
|
95
|
+
)
|
96
|
+
config.set(value: api_key, path: %w[ai api_keys open_ai])
|
97
|
+
end
|
98
|
+
|
99
|
+
def ask_for_anthropic_api_key
|
100
|
+
api_key = Soapstone::UI::Prompts::Ask.call(
|
101
|
+
question: "What is your Anthropic API key?",
|
102
|
+
help: "This is the API key you need to use to authenticate with Anthropic.",
|
103
|
+
default: config.get(:ai, :api_keys, :anthropic) || ""
|
104
|
+
)
|
105
|
+
config.set(value: api_key, path: %w[ai api_keys anthropic])
|
106
|
+
end
|
107
|
+
|
108
|
+
def ask_if_other_ai_providers_enabled
|
109
|
+
Soapstone::UI::Prompts::YesNo.call(
|
110
|
+
question: "Do you want to add another AI provider?",
|
111
|
+
help: "This will allow you to configure additional AI providers.",
|
112
|
+
default: false
|
113
|
+
)
|
114
|
+
end
|
115
|
+
|
116
|
+
def show_success_message
|
117
|
+
puts
|
118
|
+
puts pastel.green("✅ Configuration updated!")
|
119
|
+
prompt.keypress("Press any key to continue...")
|
120
|
+
end
|
121
|
+
|
122
|
+
def default_provider_options
|
123
|
+
config.get(:ai, :api_keys).map do |provider_name, _|
|
124
|
+
Soapstone::UI::Prompts::SelectAnswer.new(name: PROVIDER_OPTIONS[provider_name], value: provider_name.to_sym)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def prompt
|
129
|
+
@prompt ||= Soapstone::UI::Components::Prompt.call
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
class Soapstone::UI::Menus::GitSettings
|
2
|
+
def self.call(config:)
|
3
|
+
new(config: config).call
|
4
|
+
end
|
5
|
+
|
6
|
+
def initialize(config:)
|
7
|
+
@config = config
|
8
|
+
@pastel = Pastel.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
Soapstone::UI::Screens::Utils.clear
|
13
|
+
ask_for_default_branch
|
14
|
+
ask_for_type_style
|
15
|
+
show_success_message
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
attr_reader :pastel, :config
|
21
|
+
|
22
|
+
def ask_for_default_branch
|
23
|
+
default_branch = Soapstone::UI::Prompts::Ask.call(
|
24
|
+
question: "What is the name of your default base branch?",
|
25
|
+
help: "This is the branch that will be used as the base for new branches, it can be changed later or overwritten with --base / -b.",
|
26
|
+
default: config.get("git_settings", "default_branch") || "main"
|
27
|
+
)
|
28
|
+
config.set(value: default_branch, path: %w[git_settings default_branch])
|
29
|
+
end
|
30
|
+
|
31
|
+
def ask_for_type_style
|
32
|
+
type_style = Soapstone::UI::Prompts::Select.call(
|
33
|
+
question: "What is the type of commit style you prefer?",
|
34
|
+
answers: [
|
35
|
+
Soapstone::UI::Prompts::SelectAnswer.new(name: "Conventional", emoji: "\u{2699}\uFE0F ", value: :conventional, help: "ex. feat(scope): message"),
|
36
|
+
Soapstone::UI::Prompts::SelectAnswer.new(name: "Issue-Tracker Driven", emoji: "\u{1F4BE}", value: :issue_tracker_driven, help: "ex. Jira-123: message"),
|
37
|
+
Soapstone::UI::Prompts::SelectAnswer.new(name: "Gitmoji", emoji: "\u{1F41E}", value: :gitmoji, help: "ex. feat: message"),
|
38
|
+
Soapstone::UI::Prompts::SelectAnswer.new(name: "Hybrid", emoji: "\u{1F4A1}", value: :hybrid, help: "ex. feat(Jira-123): message"),
|
39
|
+
Soapstone::UI::Prompts::SelectAnswer.new(name: "None", emoji: "\u{1F6AA}", value: :none, help: "ex. message")
|
40
|
+
],
|
41
|
+
default: config.get("git_settings", "type_style") || "Conventional"
|
42
|
+
)
|
43
|
+
config.set(value: type_style, path: %w[git_settings type_style])
|
44
|
+
end
|
45
|
+
|
46
|
+
def show_success_message
|
47
|
+
puts
|
48
|
+
puts pastel.green("✅ Configuration updated!")
|
49
|
+
prompt.keypress("Press any key to continue...")
|
50
|
+
end
|
51
|
+
|
52
|
+
def prompt
|
53
|
+
@prompt ||= Soapstone::UI::Components::Prompt.call
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Soapstone::UI::Menus::LinearSettings
|
4
|
+
def self.call(config:)
|
5
|
+
new(config: config).call
|
6
|
+
end
|
7
|
+
|
8
|
+
def initialize(config:)
|
9
|
+
@config = config
|
10
|
+
@pastel = Pastel.new
|
11
|
+
end
|
12
|
+
|
13
|
+
def call
|
14
|
+
Soapstone::UI::Screens::Utils.clear
|
15
|
+
ask_for_enabled
|
16
|
+
return unless config.get("linear", "enabled")
|
17
|
+
|
18
|
+
ask_for_ticket_regex
|
19
|
+
ask_for_api_key
|
20
|
+
show_success_message
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :config, :pastel
|
26
|
+
|
27
|
+
def ask_for_enabled
|
28
|
+
enabled = Soapstone::UI::Prompts::YesNo.call(
|
29
|
+
question: "Enable Linear integration?",
|
30
|
+
default: config.get("linear", "enabled") || false
|
31
|
+
)
|
32
|
+
config.set(value: enabled, path: %w[linear enabled])
|
33
|
+
end
|
34
|
+
|
35
|
+
def ask_for_ticket_regex
|
36
|
+
default_regex = config.get("linear", "ticket_regex") || "(my-branch-\\d+)"
|
37
|
+
regex = Soapstone::UI::Prompts::Ask.call(
|
38
|
+
question: "Regex to capture Linear issue key from a branch name:",
|
39
|
+
help: "Example: (my-branch-\\d+) captures `my-branch-123` from `my-branch-123-my-feature`.",
|
40
|
+
default: default_regex
|
41
|
+
)
|
42
|
+
config.set(value: regex, path: %w[linear ticket_regex])
|
43
|
+
end
|
44
|
+
|
45
|
+
def ask_for_api_key
|
46
|
+
api_key = Soapstone::UI::Prompts::Ask.call(
|
47
|
+
question: "Your Linear API key:",
|
48
|
+
help: "Generate one from Linear → Settings → Security → API Keys",
|
49
|
+
default: config.get("linear", "api_key") || ""
|
50
|
+
)
|
51
|
+
config.set(value: api_key, path: %w[linear api_key])
|
52
|
+
end
|
53
|
+
|
54
|
+
def show_success_message
|
55
|
+
puts
|
56
|
+
puts pastel.green("✅ Linear settings updated!")
|
57
|
+
Soapstone::UI::Components::Prompt.call.keypress("Press any key to continue...")
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class Soapstone::UI::Prompts::Ask
|
2
|
+
def self.call(question:, default: nil, required: false, help: nil)
|
3
|
+
new(question: question, default: default, required: required, help: help).call
|
4
|
+
end
|
5
|
+
|
6
|
+
def initialize(question:, default: nil, required: false, help: nil)
|
7
|
+
@question = question
|
8
|
+
@default = default
|
9
|
+
@required = required
|
10
|
+
@help = help
|
11
|
+
end
|
12
|
+
|
13
|
+
def call
|
14
|
+
prompt.ask(question, default: default, required: required, help: help)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
attr_reader :question, :default, :required, :help
|
20
|
+
|
21
|
+
def prompt
|
22
|
+
@prompt ||= TTY::Prompt.new(help_color: :dim, enable_color: true, interrupt: :exit)
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
class Soapstone::UI::Prompts::Select
|
2
|
+
def self.call(question:, answers:, default: nil, help: nil)
|
3
|
+
new(question: question, answers: answers, default: default, help: help).call
|
4
|
+
end
|
5
|
+
|
6
|
+
def initialize(question:, answers:, default: nil, help: nil)
|
7
|
+
@question = question
|
8
|
+
@answers = answers
|
9
|
+
@default = find_default_name(default)
|
10
|
+
@help = help
|
11
|
+
end
|
12
|
+
|
13
|
+
def call
|
14
|
+
prompt.select(question, formatted_answers, default: default, help: help, show_help: :always, cycle: true)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
attr_reader :question, :answers, :default, :help
|
20
|
+
|
21
|
+
def prompt
|
22
|
+
@prompt ||= TTY::Prompt.new(help_color: :dim, active_color: :red, enable_color: true, interrupt: :exit, symbols: {marker: "\u25B6"}).tap do |p|
|
23
|
+
p.on(:keypress) do |event|
|
24
|
+
p.trigger(:keydown) if event.value == "j"
|
25
|
+
p.trigger(:keyup) if event.value == "k"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def find_default_name(default_value)
|
31
|
+
return if default_value.nil?
|
32
|
+
|
33
|
+
answers.find { |a| a.value == default_value.to_sym }&.name
|
34
|
+
end
|
35
|
+
|
36
|
+
def formatted_answers
|
37
|
+
pastel = Pastel.new
|
38
|
+
|
39
|
+
answers.map do |answer|
|
40
|
+
name = answer.name
|
41
|
+
name.prepend("#{answer.emoji} ") if answer.emoji
|
42
|
+
name.concat(" #{answer.help && pastel.dim("(#{answer.help})")}") if answer.help
|
43
|
+
|
44
|
+
{
|
45
|
+
name: name,
|
46
|
+
value: answer.value
|
47
|
+
}
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
class Soapstone::UI::Prompts::YesNo
|
2
|
+
def self.call(question:, default: nil, help: nil)
|
3
|
+
new(question: question, default: default, help: help).call
|
4
|
+
end
|
5
|
+
|
6
|
+
def initialize(question:, default: nil, help: nil)
|
7
|
+
@question = question
|
8
|
+
@default = default
|
9
|
+
@help = help
|
10
|
+
end
|
11
|
+
|
12
|
+
def call
|
13
|
+
prompt.yes?(question, help: help) do |q|
|
14
|
+
q.default(default) if default
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
attr_reader :question, :default, :help
|
21
|
+
|
22
|
+
def prompt
|
23
|
+
@prompt ||= TTY::Prompt.new(help_color: :dim, enable_color: true, interrupt: :exit)
|
24
|
+
end
|
25
|
+
end
|