startling 0.0.2
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/.gitignore +21 -0
- data/.ruby-gemset +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +78 -0
- data/Rakefile +15 -0
- data/bin/start +5 -0
- data/lib/generators/startling/configuration_generator.rb +78 -0
- data/lib/startling.rb +30 -0
- data/lib/startling/cache.rb +20 -0
- data/lib/startling/cli_options.rb +46 -0
- data/lib/startling/colorize_string.rb +58 -0
- data/lib/startling/command.rb +67 -0
- data/lib/startling/commands/base.rb +83 -0
- data/lib/startling/commands/check_for_local_mods.rb +17 -0
- data/lib/startling/commands/create_branch.rb +53 -0
- data/lib/startling/commands/create_pull_request.rb +35 -0
- data/lib/startling/commands/label_pull_request.rb +15 -0
- data/lib/startling/configuration.rb +106 -0
- data/lib/startling/git_local.rb +61 -0
- data/lib/startling/github.rb +16 -0
- data/lib/startling/github/api.rb +106 -0
- data/lib/startling/github/pull_request.rb +54 -0
- data/lib/startling/github/repo.rb +44 -0
- data/lib/startling/handlers/default_pull_request_handler.rb +21 -0
- data/lib/startling/handlers/pull_request_handler_base.rb +21 -0
- data/lib/startling/markdown.rb +7 -0
- data/lib/startling/shell.rb +11 -0
- data/lib/startling/version.rb +3 -0
- data/spec/spec_helper.rb +19 -0
- data/spec/startling/commands/base_spec.rb +32 -0
- data/spec/startling/configuration_spec.rb +127 -0
- data/spec/startling/git_local_spec.rb +22 -0
- data/spec/startling/github/pull_request_spec.rb +37 -0
- data/spec/startling_configuration_spec.rb +16 -0
- data/spec/startling_spec.rb +122 -0
- data/spec/support/dotenv.rb +2 -0
- data/spec/support/tokens.rb +5 -0
- data/spec/support/vcr.rb +16 -0
- data/spec/vcr_cassettes/bin_start_starts_stories.yml +564 -0
- data/spec/vcr_cassettes/bin_start_starts_stories_pr_body.yml +644 -0
- data/startling.gemspec +36 -0
- metadata +297 -0
@@ -0,0 +1,83 @@
|
|
1
|
+
module Startling
|
2
|
+
module Commands
|
3
|
+
class Base
|
4
|
+
attr_reader :cli_options
|
5
|
+
|
6
|
+
def self.run(attrs={})
|
7
|
+
new(attrs).execute
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(attrs={})
|
11
|
+
@cli_options = attrs
|
12
|
+
attrs.each do |attr, value|
|
13
|
+
self.class.__send__(:attr_reader, attr)
|
14
|
+
instance_variable_set("@#{attr}", value)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def execute
|
19
|
+
raise NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.load_configuration
|
23
|
+
loaded_configuration_path = Startling::Configuration.load_configuration
|
24
|
+
if loaded_configuration_path
|
25
|
+
puts "Loading configuration #{loaded_configuration_path}"
|
26
|
+
else
|
27
|
+
puts "Using default configuration"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.load_commands
|
32
|
+
loaded_commands_path = Startling::Configuration.load_commands
|
33
|
+
if loaded_commands_path
|
34
|
+
puts "Loading commands #{loaded_commands_path}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.load_handlers
|
39
|
+
loaded_handlers_path = Startling::Configuration.load_handlers
|
40
|
+
if loaded_handlers_path
|
41
|
+
puts "Loading handlers #{loaded_handlers_path}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def handler_class(handler)
|
46
|
+
begin
|
47
|
+
Startling::Handlers.const_get(to_camel_case(handler.to_s))
|
48
|
+
rescue NameError
|
49
|
+
print_name_error_message(handler, Startling::Configuration::DEFAULT_HANDLER_PATH)
|
50
|
+
exit
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def command_class(command)
|
55
|
+
begin
|
56
|
+
Startling::Commands.const_get(to_camel_case(command.to_s))
|
57
|
+
rescue NameError
|
58
|
+
print_name_error_message(command, Startling::Configuration::DEFAULT_COMMAND_PATH)
|
59
|
+
exit
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def print_name_error_message(name, path)
|
64
|
+
puts "Error loading #{to_camel_case(name.to_s)}. Is it defined in #{path}?"
|
65
|
+
end
|
66
|
+
|
67
|
+
def print_args(context)
|
68
|
+
puts "== Instance vars from #{context} ==>"
|
69
|
+
instance_variables.each do |var|
|
70
|
+
puts "#{var}: #{instance_variable_get(var)}"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def to_camel_case(lower_case_and_underscored_word, first_letter_in_uppercase = true)
|
75
|
+
if first_letter_in_uppercase
|
76
|
+
lower_case_and_underscored_word.to_s.gsub(/\/(.?)/) { "::" + $1.upcase }.gsub(/(^|_)(.)/) { $2.upcase }
|
77
|
+
else
|
78
|
+
lower_case_and_underscored_word.first + camelize(lower_case_and_underscored_word)[1..-1]
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require_relative '../git_local'
|
2
|
+
|
3
|
+
require_relative "base"
|
4
|
+
|
5
|
+
module Startling
|
6
|
+
module Commands
|
7
|
+
class CheckForLocalMods < Base
|
8
|
+
def execute
|
9
|
+
return if git.status.empty?
|
10
|
+
|
11
|
+
puts "Local modifications detected, please stash or something."
|
12
|
+
system("git status -s")
|
13
|
+
exit 1
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require_relative "base"
|
2
|
+
|
3
|
+
module Startling
|
4
|
+
module Commands
|
5
|
+
class CreateBranch < Base
|
6
|
+
def execute
|
7
|
+
create_branch if branch_name != git.current_branch
|
8
|
+
branch_name
|
9
|
+
end
|
10
|
+
|
11
|
+
def repo
|
12
|
+
@repo ||= Github.repo(git.repo_name)
|
13
|
+
end
|
14
|
+
|
15
|
+
def create_branch
|
16
|
+
puts "Creating branch #{branch_name}..."
|
17
|
+
git.create_remote_branch(branch_name, base_branch: "origin/#{default_branch}")
|
18
|
+
end
|
19
|
+
|
20
|
+
def default_branch
|
21
|
+
repo.default_branch
|
22
|
+
end
|
23
|
+
|
24
|
+
def branch_name
|
25
|
+
@branch_name ||= get_branch_name
|
26
|
+
end
|
27
|
+
|
28
|
+
def get_branch_name
|
29
|
+
if branch.empty?
|
30
|
+
if git.current_branch_is_a_feature_branch?
|
31
|
+
return git.current_branch
|
32
|
+
else
|
33
|
+
abort "Branch name must be specified when current branch is not feature/."
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
branch.gsub!(/feature\//, '')
|
38
|
+
"feature/#{branch}".gsub(/\s+/, '-')
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def branch
|
44
|
+
@branch ||=
|
45
|
+
if args.length > 1
|
46
|
+
args[1..-1].map(&:downcase).join('-')
|
47
|
+
else
|
48
|
+
ask("Enter branch name (enter for current branch): ")
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require_relative "base"
|
2
|
+
require_relative 'create_branch'
|
3
|
+
|
4
|
+
module Startling
|
5
|
+
module Commands
|
6
|
+
class CreatePullRequest < Base
|
7
|
+
def execute
|
8
|
+
create_branch
|
9
|
+
open_pull_request
|
10
|
+
end
|
11
|
+
|
12
|
+
def create_branch
|
13
|
+
@branch_name ||= CreateBranch.run(args: args, git: git)
|
14
|
+
end
|
15
|
+
|
16
|
+
def open_pull_request
|
17
|
+
puts "Opening pull request..."
|
18
|
+
git.create_empty_commit(pull_request_handler.commit_message)
|
19
|
+
git.push_origin_head
|
20
|
+
|
21
|
+
repo.open_pull_request title: pull_request_handler.title,
|
22
|
+
body: pull_request_handler.body, branch: @branch_name
|
23
|
+
end
|
24
|
+
|
25
|
+
def repo
|
26
|
+
@repo ||= Github.repo(git.repo_name)
|
27
|
+
end
|
28
|
+
|
29
|
+
def pull_request_handler
|
30
|
+
handler_name = Startling.pull_request_handler || :default_pull_request_handler
|
31
|
+
handler_class(handler_name).new(cli_options)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require_relative "base"
|
2
|
+
|
3
|
+
module Startling
|
4
|
+
module Commands
|
5
|
+
class LabelPullRequest < Base
|
6
|
+
def execute
|
7
|
+
repo.set_labels_for_issue issue_id: pull_request.id, labels: Startling.pull_request_labels
|
8
|
+
end
|
9
|
+
|
10
|
+
def repo
|
11
|
+
Github.repo(git.repo_name)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
require 'startling/git_local'
|
2
|
+
require 'startling/github'
|
3
|
+
require 'startling/colorize_string'
|
4
|
+
require 'startling/commands/label_pull_request'
|
5
|
+
require 'startling/handlers/default_pull_request_handler'
|
6
|
+
|
7
|
+
module Startling
|
8
|
+
class Configuration
|
9
|
+
DEFAULT_COMMAND_PATH = "startling/commands"
|
10
|
+
DEFAULT_HANDLER_PATH = "startling/handlers"
|
11
|
+
DEFAULT_WIP_LIMIT = 4
|
12
|
+
DEFAULT_COMMIT_MESSAGE = "Startling"
|
13
|
+
DEFAULT_BODY = "Startling"
|
14
|
+
|
15
|
+
DEFAULT_STARTLINGFILES = [
|
16
|
+
'startlingfile.rb',
|
17
|
+
'Startlingfile.rb'
|
18
|
+
].freeze
|
19
|
+
|
20
|
+
attr_accessor :cache_dir, :root_dir, :wip_limit, :repos, :story_handler, :pull_request_body,
|
21
|
+
:pull_request_handler, :pull_request_labels, :pull_request_commit_message, :cli_options
|
22
|
+
|
23
|
+
def initialize
|
24
|
+
@cache_dir = Dir.pwd
|
25
|
+
@root_dir = Dir.pwd
|
26
|
+
@wip_limit = DEFAULT_WIP_LIMIT
|
27
|
+
@repos = []
|
28
|
+
@story_handler = nil
|
29
|
+
@pull_request_handler = nil
|
30
|
+
@pull_request_body = DEFAULT_BODY
|
31
|
+
@pull_request_commit_message = DEFAULT_COMMIT_MESSAGE
|
32
|
+
@pull_request_labels = []
|
33
|
+
@cli_options = []
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.load_configuration
|
37
|
+
DEFAULT_STARTLINGFILES.each do |file_name|
|
38
|
+
if Dir.entries(Startling::GitLocal.new.project_root).include? file_name
|
39
|
+
load "#{Startling::GitLocal.new.project_root}/#{file_name}"
|
40
|
+
return file_name
|
41
|
+
end
|
42
|
+
end
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.load_commands(path=DEFAULT_COMMAND_PATH)
|
47
|
+
load_path(path)
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.load_handlers(path=DEFAULT_HANDLER_PATH)
|
51
|
+
load_path(path)
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.load_path(path)
|
55
|
+
directory = File.join(Startling::GitLocal.new.project_root, path, "*")
|
56
|
+
return unless directory
|
57
|
+
Dir.glob(directory).each do |file|
|
58
|
+
load "#{file}"
|
59
|
+
end
|
60
|
+
directory
|
61
|
+
end
|
62
|
+
|
63
|
+
def hook_commands
|
64
|
+
@hooks ||= HookCommands.new
|
65
|
+
end
|
66
|
+
|
67
|
+
def add_cli_option(abbr_switch, full_switch, description, required=false)
|
68
|
+
@cli_options << CliOption.new(abbr_switch, full_switch, description, required)
|
69
|
+
end
|
70
|
+
|
71
|
+
def cli_options
|
72
|
+
@cli_options ||= []
|
73
|
+
end
|
74
|
+
|
75
|
+
class HookCommands
|
76
|
+
attr_accessor :before_story_start, :after_story_start,
|
77
|
+
:before_pull_request, :create_pull_request, :after_pull_request
|
78
|
+
|
79
|
+
def initialize
|
80
|
+
@before_story_start = []
|
81
|
+
@after_story_start = []
|
82
|
+
@before_pull_request = []
|
83
|
+
@after_pull_request = [:label_pull_request]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
class CliOption
|
88
|
+
attr_reader :abbr_switch, :description, :full_switch
|
89
|
+
|
90
|
+
def initialize(abbr_switch, full_switch, description, required)
|
91
|
+
@abbr_switch = abbr_switch
|
92
|
+
@full_switch = full_switch
|
93
|
+
@description = description
|
94
|
+
@required = required
|
95
|
+
end
|
96
|
+
|
97
|
+
def long_switch
|
98
|
+
@required ? "--#{@full_switch} #{@full_switch}" : "--#{@full_switch}"
|
99
|
+
end
|
100
|
+
|
101
|
+
def sym
|
102
|
+
full_switch.to_s
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require_relative 'shell'
|
2
|
+
|
3
|
+
module Startling
|
4
|
+
class GitLocal
|
5
|
+
def current_branch
|
6
|
+
`git symbolic-ref -q --short HEAD`.strip
|
7
|
+
end
|
8
|
+
|
9
|
+
def checkout_branch(branch)
|
10
|
+
Shell.run "git checkout #{branch}"
|
11
|
+
end
|
12
|
+
|
13
|
+
def status
|
14
|
+
Shell.run "git status --porcelain"
|
15
|
+
end
|
16
|
+
|
17
|
+
def remote_branches
|
18
|
+
Shell.run "git branch -r"
|
19
|
+
end
|
20
|
+
|
21
|
+
def local_branches
|
22
|
+
Shell.run "git branch"
|
23
|
+
end
|
24
|
+
|
25
|
+
def create_empty_commit(message)
|
26
|
+
Shell.run "git commit --allow-empty -m #{message}"
|
27
|
+
end
|
28
|
+
|
29
|
+
def create_remote_branch(branch_name, base_branch: 'origin/master')
|
30
|
+
Shell.run "git fetch -q"
|
31
|
+
Shell.run "git checkout -q #{branch_name} 2>/dev/null || git checkout -q -b #{branch_name} #{base_branch}"
|
32
|
+
end
|
33
|
+
|
34
|
+
def push_origin_head
|
35
|
+
Shell.run "git push -qu origin HEAD"
|
36
|
+
end
|
37
|
+
|
38
|
+
def destroy_branch(branch)
|
39
|
+
Shell.run "git push origin :#{branch}" if remote_branches.include? branch
|
40
|
+
Shell.run "git branch -D #{branch}" if local_branches.include? branch
|
41
|
+
end
|
42
|
+
|
43
|
+
def repo_name
|
44
|
+
remote_url[%r{([^/:]+/[^/]+)\.git}, 1]
|
45
|
+
end
|
46
|
+
|
47
|
+
def current_branch_is_a_feature_branch?
|
48
|
+
current_branch =~ %r{^feature/}
|
49
|
+
end
|
50
|
+
|
51
|
+
def project_root
|
52
|
+
`git rev-parse --show-toplevel`.strip
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def remote_url
|
58
|
+
`git config --get remote.origin.url`.strip
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
require 'parallel'
|
2
|
+
require 'faraday'
|
3
|
+
require_relative 'pull_request'
|
4
|
+
|
5
|
+
module Startling
|
6
|
+
module Github
|
7
|
+
class Api
|
8
|
+
def initialize
|
9
|
+
@lock = Mutex.new
|
10
|
+
@repositories = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def pull_requests(repo)
|
14
|
+
raw = octokit.pull_requests(repo, state: 'open')
|
15
|
+
Parallel.map(raw, in_threads: raw.count) do |pull|
|
16
|
+
PullRequest.new(pull).tap do |pull_request|
|
17
|
+
pull_request.labels = labels_for_issue(repo_name: repo, issue_id: pull_request.id)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def repository(name)
|
23
|
+
Repo.new name, self
|
24
|
+
end
|
25
|
+
|
26
|
+
def repository_attributes(name)
|
27
|
+
octokit.repository(name)
|
28
|
+
end
|
29
|
+
|
30
|
+
def open_pull_request(title: nil, body: nil, branch: nil,
|
31
|
+
destination_branch: nil, repo_name: nil)
|
32
|
+
response = octokit.create_pull_request(repo_name, destination_branch, branch, title, body)
|
33
|
+
response.data
|
34
|
+
rescue Octokit::UnprocessableEntity => e
|
35
|
+
puts "Failed to open pull request, it may be open already"
|
36
|
+
p e
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
|
40
|
+
def labels_for_issue(repo_name: nil, issue_id: nil)
|
41
|
+
octokit.labels_for_issue(repo_name, issue_id)
|
42
|
+
end
|
43
|
+
|
44
|
+
def set_labels_for_issue(repo_name: nil, issue_id: nil, labels: nil)
|
45
|
+
octokit.replace_all_labels(repo_name, issue_id, labels)
|
46
|
+
end
|
47
|
+
|
48
|
+
def pull_request(repo_name, branch)
|
49
|
+
repository(repo_name).pull_request(branch)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
attr_reader :lock, :repositories
|
54
|
+
|
55
|
+
def octokit
|
56
|
+
lock.synchronize do
|
57
|
+
@octokit ||= build_octokit
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def build_octokit
|
62
|
+
stack = faraday_builder_class.new do |builder|
|
63
|
+
#builder.response :logger
|
64
|
+
builder.use Octokit::Response::RaiseError
|
65
|
+
builder.adapter Faraday.default_adapter
|
66
|
+
end
|
67
|
+
Octokit.middleware = stack
|
68
|
+
Octokit::Client.new access_token: access_token
|
69
|
+
end
|
70
|
+
|
71
|
+
def faraday_builder_class
|
72
|
+
defined?(Faraday::RackBuilder) ? Faraday::RackBuilder : Faraday::Builder
|
73
|
+
end
|
74
|
+
|
75
|
+
def access_token
|
76
|
+
Startling.cache.fetch('.github_access_token') do
|
77
|
+
client = Octokit::Client.new(login: prompt_for_login, password: prompt_for_password)
|
78
|
+
authorization_opts = {}
|
79
|
+
authorization_opts[:scopes] = ["repo"]
|
80
|
+
authorization_opts[:note] = "startling on #{`echo $HOSTNAME`}"
|
81
|
+
begin
|
82
|
+
client.create_authorization(authorization_opts)[:token]
|
83
|
+
rescue Octokit::OneTimePasswordRequired
|
84
|
+
authorization_opts[:headers] = { "X-GitHub-OTP" => prompt_for_otp }
|
85
|
+
retry
|
86
|
+
rescue Octokit::Unauthorized
|
87
|
+
puts "Invalid username or password, try again."
|
88
|
+
retry
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def prompt_for_login
|
94
|
+
ask("Enter your Github username: ")
|
95
|
+
end
|
96
|
+
|
97
|
+
def prompt_for_password
|
98
|
+
ask("Enter your Github password: ") { |q| q.echo = false }
|
99
|
+
end
|
100
|
+
|
101
|
+
def prompt_for_otp
|
102
|
+
ask("Enter your one time password: ")
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|