startling 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|