startling 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +21 -0
  3. data/.ruby-gemset +1 -0
  4. data/Gemfile +4 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +78 -0
  7. data/Rakefile +15 -0
  8. data/bin/start +5 -0
  9. data/lib/generators/startling/configuration_generator.rb +78 -0
  10. data/lib/startling.rb +30 -0
  11. data/lib/startling/cache.rb +20 -0
  12. data/lib/startling/cli_options.rb +46 -0
  13. data/lib/startling/colorize_string.rb +58 -0
  14. data/lib/startling/command.rb +67 -0
  15. data/lib/startling/commands/base.rb +83 -0
  16. data/lib/startling/commands/check_for_local_mods.rb +17 -0
  17. data/lib/startling/commands/create_branch.rb +53 -0
  18. data/lib/startling/commands/create_pull_request.rb +35 -0
  19. data/lib/startling/commands/label_pull_request.rb +15 -0
  20. data/lib/startling/configuration.rb +106 -0
  21. data/lib/startling/git_local.rb +61 -0
  22. data/lib/startling/github.rb +16 -0
  23. data/lib/startling/github/api.rb +106 -0
  24. data/lib/startling/github/pull_request.rb +54 -0
  25. data/lib/startling/github/repo.rb +44 -0
  26. data/lib/startling/handlers/default_pull_request_handler.rb +21 -0
  27. data/lib/startling/handlers/pull_request_handler_base.rb +21 -0
  28. data/lib/startling/markdown.rb +7 -0
  29. data/lib/startling/shell.rb +11 -0
  30. data/lib/startling/version.rb +3 -0
  31. data/spec/spec_helper.rb +19 -0
  32. data/spec/startling/commands/base_spec.rb +32 -0
  33. data/spec/startling/configuration_spec.rb +127 -0
  34. data/spec/startling/git_local_spec.rb +22 -0
  35. data/spec/startling/github/pull_request_spec.rb +37 -0
  36. data/spec/startling_configuration_spec.rb +16 -0
  37. data/spec/startling_spec.rb +122 -0
  38. data/spec/support/dotenv.rb +2 -0
  39. data/spec/support/tokens.rb +5 -0
  40. data/spec/support/vcr.rb +16 -0
  41. data/spec/vcr_cassettes/bin_start_starts_stories.yml +564 -0
  42. data/spec/vcr_cassettes/bin_start_starts_stories_pr_body.yml +644 -0
  43. data/startling.gemspec +36 -0
  44. 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,16 @@
1
+ require 'octokit'
2
+
3
+ require_relative 'github/repo'
4
+ require_relative 'github/api'
5
+
6
+ module Startling
7
+ module Github
8
+ def self.api
9
+ @api ||= Github::Api.new
10
+ end
11
+
12
+ def self.repo(name)
13
+ api.repository(name)
14
+ end
15
+ end
16
+ 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