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.
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