thegarage-gitx 1.0.0.alpha

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.
@@ -0,0 +1,139 @@
1
+ require "thor"
2
+ require 'rest_client'
3
+ require 'thegarage/gitx'
4
+
5
+ module Thegarage
6
+ module Gitx
7
+ class CLI < Thor
8
+ include Thegarage::Gitx
9
+ include Thegarage::Gitx::Git
10
+ include Thegarage::Gitx::Github
11
+
12
+ PULL_REQUEST_DESCRIPTION = "\n\n" + <<-EOS.dedent
13
+ # Use GitHub flavored Markdown http://github.github.com/github-flavored-markdown/
14
+ # Links to screencasts or screenshots with a desciption of what this is showcasing. For architectual changes please include diagrams that will make it easier for the reviewer to understand the change. Format is ![title](url).
15
+ # Link to ticket describing feature/bug (plantain, JIRA, bugzilla). Format is [title](url).
16
+ # Brief description of the change, and how it accomplishes the task they set out to do.
17
+ EOS
18
+
19
+ method_option :quiet, :type => :boolean, :aliases => '-q'
20
+ method_option :trace, :type => :boolean, :aliases => '-v'
21
+ def initialize(*args)
22
+ super(*args)
23
+ RestClient.proxy = ENV['HTTPS_PROXY'] if ENV.has_key?('HTTPS_PROXY')
24
+ RestClient.log = Logger.new(STDOUT) if options[:trace]
25
+ end
26
+
27
+ desc "reviewrequest", "Create a pull request on github"
28
+ method_option :description, :type => :string, :aliases => '-d', :desc => 'pull request description'
29
+ # @see http://developer.github.com/v3/pulls/
30
+ def reviewrequest
31
+ update
32
+
33
+ token = authorization_token
34
+ description = options[:description] || editor_input(PULL_REQUEST_DESCRIPTION)
35
+ branch = current_branch
36
+ repo = current_repo
37
+ url = create_pull_request token, branch, repo, description
38
+ say "Pull request created: #{url}"
39
+ end
40
+
41
+ # TODO: use --no-edit to skip merge messages
42
+ # TODO: use pull --rebase to skip merge commit
43
+ desc 'update', 'Update the current branch with latest changes from the remote feature branch and master'
44
+ def update
45
+ branch = current_branch
46
+
47
+ say 'updating '
48
+ say "#{branch} ", :green
49
+ say "to have most recent changes from "
50
+ say Thegarage::Gitx::BASE_BRANCH, :green
51
+
52
+ run_cmd "git pull origin #{branch}" rescue nil
53
+ run_cmd "git pull origin #{Thegarage::Gitx::BASE_BRANCH}"
54
+ run_cmd 'git push origin HEAD'
55
+ end
56
+
57
+ desc 'cleanup', 'Cleanup branches that have been merged into master from the repo'
58
+ def cleanup
59
+ run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
60
+ run_cmd "git pull"
61
+ run_cmd 'git remote prune origin'
62
+
63
+ say "Deleting branches that have been merged into "
64
+ say Thegarage::Gitx::BASE_BRANCH, :green
65
+ branches(:merged => true, :remote => true).each do |branch|
66
+ run_cmd "git push origin --delete #{branch}" unless aggregate_branch?(branch)
67
+ end
68
+ branches(:merged => true).each do |branch|
69
+ run_cmd "git branch -d #{branch}" unless aggregate_branch?(branch)
70
+ end
71
+ end
72
+
73
+ desc 'track', 'set the current branch to track the remote branch with the same name'
74
+ def track
75
+ track_branch current_branch
76
+ end
77
+
78
+ desc 'start', 'start a new git branch with latest changes from master'
79
+ def start(branch_name = nil)
80
+ unless branch_name
81
+ example_branch = %w{ api-fix-invalid-auth desktop-cleanup-avatar-markup share-form-add-edit-link }.sort_by { rand }.first
82
+ repo = Grit::Repo.new(Dir.pwd)
83
+ remote_branches = repo.remotes.collect {|b| b.name.split('/').last }
84
+ until branch_name = ask("What would you like to name your branch? (ex: #{example_branch})") {|q|
85
+ q.validate = Proc.new { |branch|
86
+ branch =~ /^[A-Za-z0-9\-_]+$/ && !remote_branches.include?(branch)
87
+ }
88
+ }
89
+ end
90
+ end
91
+
92
+ run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
93
+ run_cmd 'git pull'
94
+ run_cmd "git checkout -b #{branch_name}"
95
+ end
96
+
97
+ desc 'share', 'Share the current branch in the remote repository'
98
+ def share
99
+ share_branch current_branch
100
+ end
101
+
102
+ desc 'integrate', 'integrate the current branch into one of the aggregate development branches'
103
+ def integrate(target_branch = 'staging')
104
+ branch = current_branch
105
+
106
+ update
107
+ integrate_branch(branch, target_branch)
108
+ run_cmd "git checkout #{branch}"
109
+ end
110
+
111
+ desc 'nuke', 'nuke the specified aggregate branch and reset it to a known good state'
112
+ method_option :destination, :type => :string, :aliases => '-d', :desc => 'destination branch to reset to'
113
+ def nuke(bad_branch)
114
+ default_good_branch = "last_known_good_#{bad_branch}"
115
+ good_branch = options[:destination] || ask("What branch do you want to reset #{bad_branch} to? (default: #{default_good_branch})")
116
+ good_branch = default_good_branch if good_branch.length == 0
117
+ good_branch = "last_known_good_#{good_branch}" unless good_branch.starts_with?('last_known_good_')
118
+
119
+ removed_branches = nuke_branch(bad_branch, good_branch)
120
+ nuke_branch("last_known_good_#{bad_branch}", good_branch)
121
+ end
122
+
123
+ desc 'release', 'release the current branch to production'
124
+ def release
125
+ branch = current_branch
126
+ assert_not_protected_branch!(branch, 'release')
127
+ update
128
+
129
+ return unless yes?("Release #{branch} to production? (y/n)", :green)
130
+ run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
131
+ run_cmd "git pull origin #{Thegarage::Gitx::BASE_BRANCH}"
132
+ run_cmd "git pull . #{branch}"
133
+ run_cmd "git push origin HEAD"
134
+ integrate_branch('master', 'staging')
135
+ cleanup
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,178 @@
1
+ require 'grit'
2
+ require 'pathname'
3
+
4
+ module Thegarage
5
+ module Gitx
6
+ module Git
7
+ AGGREGATE_BRANCHES = %w{ staging prototype }
8
+ RESERVED_BRANCHES = %w{ HEAD master next_release } + AGGREGATE_BRANCHES
9
+
10
+ private
11
+ def assert_not_protected_branch!(branch, action)
12
+ raise "Cannot #{action} reserved branch" if RESERVED_BRANCHES.include?(branch) || aggregate_branch?(branch)
13
+ end
14
+
15
+ # lookup the current branch of the PWD
16
+ def current_branch
17
+ repo = Grit::Repo.new(Dir.pwd)
18
+ Grit::Head.current(repo).name
19
+ end
20
+
21
+ # lookup the current repository of the PWD
22
+ # ex: git@github.com:socialcast/thegarage/gitx.git OR https://github.com/socialcast/thegarage/gitx.git
23
+ def current_repo
24
+ repo = `git config -z --get remote.origin.url`.strip
25
+ repo.gsub(/\.git$/,'').split(/[:\/]/).last(2).join('/')
26
+ end
27
+
28
+ # @returns [String] github username (ex: 'wireframe') of the current github.user
29
+ # @returns empty [String] when no github.user is set on the system
30
+ def current_user
31
+ `git config -z --get github.user`.strip
32
+ end
33
+
34
+ # @returns [String] auth token stored in git (current repo, user config or installed global settings)
35
+ def github_auth_token
36
+ `git config -z --get thegarage.gitx.github_auth_token`.strip
37
+ end
38
+
39
+ # store new auth token in the local project git config
40
+ def github_auth_token=(new_token)
41
+ `git config thegarage.gitx.github_auth_token "#{new_token}"`
42
+ end
43
+
44
+ # retrieve a list of branches
45
+ def branches(options = {})
46
+ branches = []
47
+ args = []
48
+ args << '-r' if options[:remote]
49
+ args << "--merged #{options[:merged].is_a?(String) ? options[:merged] : ''}" if options[:merged]
50
+ output = `git branch #{args.join(' ')}`.split("\n")
51
+ output.each do |branch|
52
+ branch = branch.gsub(/\*/, '').strip.split(' ').first
53
+ branch = branch.split('/').last if options[:remote]
54
+ branches << branch unless RESERVED_BRANCHES.include?(branch)
55
+ end
56
+ branches.uniq
57
+ end
58
+
59
+ # reset the specified branch to the same set of commits as the destination branch
60
+ # reverts commits on aggregate branches back to a known good state
61
+ # returns list of branches that were removed
62
+ def nuke_branch(branch, head_branch)
63
+ return [] if branch == head_branch
64
+ raise "Only aggregate branches are allowed to be reset: #{AGGREGATE_BRANCHES}" unless aggregate_branch?(branch)
65
+ say "Resetting "
66
+ say "#{branch} ", :green
67
+ say "branch to "
68
+ say head_branch, :green
69
+
70
+ run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
71
+ refresh_branch_from_remote head_branch
72
+ removed_branches = branches(:remote => true, :merged => "origin/#{branch}") - branches(:remote => true, :merged => "origin/#{head_branch}")
73
+ run_cmd "git branch -D #{branch}" rescue nil
74
+ run_cmd "git push origin --delete #{branch}" rescue nil
75
+ run_cmd "git checkout -b #{branch}"
76
+ share_branch branch
77
+ run_cmd "git checkout #{Thegarage::Gitx::BASE_BRANCH}"
78
+
79
+ removed_branches
80
+ end
81
+
82
+ # share the local branch in the remote repo
83
+ def share_branch(branch)
84
+ run_cmd "git push origin #{branch}"
85
+ track_branch branch
86
+ end
87
+
88
+ def track_branch(branch)
89
+ run_cmd "git branch --set-upstream #{branch} origin/#{branch}"
90
+ end
91
+
92
+ # integrate a branch into a destination aggregate branch
93
+ # blow away the local aggregate branch to ensure pulling into most recent "clean" branch
94
+ def integrate_branch(branch, destination_branch)
95
+ assert_not_protected_branch!(branch, 'integrate') unless aggregate_branch?(destination_branch)
96
+ raise "Only aggregate branches are allowed for integration: #{AGGREGATE_BRANCHES}" unless aggregate_branch?(destination_branch) || destination_branch == Thegarage::Gitx::BASE_BRANCH
97
+ say "Integrating "
98
+ say "#{branch} ", :green
99
+ say "into "
100
+ say destination_branch, :green
101
+
102
+ refresh_branch_from_remote destination_branch
103
+ run_cmd "git pull . #{branch}"
104
+ run_cmd "git push origin HEAD"
105
+ run_cmd "git checkout #{branch}"
106
+ end
107
+
108
+ # nuke local branch and pull fresh version from remote repo
109
+ def refresh_branch_from_remote(destination_branch)
110
+ run_cmd "git branch -D #{destination_branch}" rescue nil
111
+ run_cmd "git fetch origin"
112
+ run_cmd "git checkout #{destination_branch}"
113
+ end
114
+
115
+ def aggregate_branch?(branch)
116
+ AGGREGATE_BRANCHES.include?(branch) || branch.starts_with?('last_known_good')
117
+ end
118
+
119
+ # build a summary of changes
120
+ def changelog_summary(branch)
121
+ changes = `git diff --stat origin/#{Thegarage::Gitx::BASE_BRANCH}...#{branch}`.split("\n")
122
+ stats = changes.pop
123
+ if changes.length > 5
124
+ dirs = changes.map do |file_change|
125
+ filename = "#{file_change.split.first}"
126
+ dir = filename.gsub(/\/[^\/]+$/, '')
127
+ dir
128
+ end
129
+ dir_counts = Hash.new(0)
130
+ dirs.each {|dir| dir_counts[dir] += 1 }
131
+ changes = dir_counts.to_a.sort_by {|k,v| v}.reverse.first(5).map {|k,v| "#{k} (#{v} file#{'s' if v > 1})"}
132
+ end
133
+ (changes + [stats]).join("\n")
134
+ end
135
+
136
+ # launch configured editor to retreive message/string
137
+ def editor_input(initial_text = '')
138
+ require 'tempfile'
139
+ Tempfile.open('reviewrequest.md') do |f|
140
+ f << initial_text
141
+ f.flush
142
+
143
+ editor = ENV['EDITOR'] || 'vi'
144
+ flags = case editor
145
+ when 'mate', 'emacs'
146
+ '-w'
147
+ when 'mvim'
148
+ '-f'
149
+ else
150
+ ''
151
+ end
152
+ pid = fork { exec "#{editor} #{flags} #{f.path}" }
153
+ Process.waitpid(pid)
154
+ description = File.read(f.path)
155
+ description.gsub(/^\#.*/, '').chomp.strip
156
+ end
157
+ end
158
+
159
+ # load SC Git Extensions Config YAML
160
+ # @returns [Hash] of configuration options from YAML file (if it exists)
161
+ def config
162
+ @config ||= begin
163
+ if config_file.exist?
164
+ YAML.load_file(config_file)
165
+ else
166
+ {}
167
+ end
168
+ end
169
+ end
170
+
171
+ # @returns a [Pathname] for the scgitx.yml Config File
172
+ # from either ENV['SCGITX_CONFIG_PATH'] or default $PWD/config/scgitx.yml
173
+ def config_file
174
+ Pathname((ENV['SCGITX_CONFIG_PATH'] || ([Dir.pwd, '/config/scgitx.yml']).join))
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,60 @@
1
+ require 'thegarage/gitx/git'
2
+ require 'rest_client'
3
+ require 'json'
4
+
5
+ module Thegarage
6
+ module Gitx
7
+ module Github
8
+ include Thegarage::Gitx::Git
9
+
10
+ private
11
+ # request github authorization token
12
+ # User-Agent is required
13
+ # store the token in ~/.socialcast/credentials.yml for future reuse
14
+ # @see http://developer.github.com/v3/oauth/#scopes
15
+ # @see http://developer.github.com/v3/#user-agent-required
16
+ def authorization_token
17
+ auth_token = github_auth_token
18
+ return auth_token if auth_token
19
+
20
+ username = current_user
21
+ raise "Github user not configured. Run: `git config --global github.user 'me@email.com'`" if username.empty?
22
+ password = ask("Github password for #{username}: ") { |q| q.echo = false }
23
+
24
+ payload = {:scopes => ['repo'], :note => 'Socialcast Git eXtension', :note_url => 'https://github.com/socialcast/thegarage/gitx'}.to_json
25
+ response = RestClient::Request.new(:url => "https://api.github.com/authorizations", :method => "POST", :user => username, :password => password, :payload => payload, :headers => {:accept => :json, :content_type => :json, :user_agent => 'thegarage/gitx'}).execute
26
+ data = JSON.parse response.body
27
+ token = data['token']
28
+ github_auth_token = token
29
+ token
30
+ rescue RestClient::Exception => e
31
+ process_error e
32
+ throw e
33
+ end
34
+
35
+ # returns the url of the created pull request
36
+ # @see http://developer.github.com/v3/pulls/
37
+ def create_pull_request(token, branch, repo, body)
38
+ payload = {:title => branch, :base => Thegarage::Gitx::BASE_BRANCH, :head => branch, :body => body}.to_json
39
+ say "Creating pull request for "
40
+ say "#{branch} ", :green
41
+ say "against "
42
+ say "#{Thegarage::Gitx::BASE_BRANCH} ", :green
43
+ say "in "
44
+ say repo, :green
45
+ response = RestClient::Request.new(:url => "https://api.github.com/repos/#{repo}/pulls", :method => "POST", :payload => payload, :headers => {:accept => :json, :content_type => :json, 'Authorization' => "token #{token}"}).execute
46
+ data = JSON.parse response.body
47
+ url = data['html_url']
48
+ url
49
+ rescue RestClient::Exception => e
50
+ process_error e
51
+ throw e
52
+ end
53
+
54
+ def process_error(e)
55
+ data = JSON.parse e.http_body
56
+ say "Failed to create pull request: #{data['message']}", :red
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,11 @@
1
+ class String
2
+ def undent
3
+ a = $1 if match(/\A(\s+)(.*\n)(?:\1.*\n)*\z/)
4
+ gsub(/^#{a}/,'')
5
+ end
6
+ alias :dedent :undent
7
+
8
+ def starts_with?(characters)
9
+ !!self.match(/^#{characters}/)
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ module Thegarage
2
+ module Gitx
3
+ VERSION = '1.0.0.alpha'
4
+ end
5
+ end
@@ -0,0 +1,21 @@
1
+ require "thegarage/gitx/version"
2
+ require 'thegarage/gitx/version'
3
+ require 'thegarage/gitx/string_extensions'
4
+ require 'thegarage/gitx/git'
5
+ require 'thegarage/gitx/github'
6
+
7
+
8
+ module Thegarage
9
+ module Gitx
10
+ BASE_BRANCH = 'master'
11
+
12
+ private
13
+
14
+ # execute a shell command and raise an error if non-zero exit code is returned
15
+ def run_cmd(cmd)
16
+ say "\n$ "
17
+ say cmd.gsub("'", ''), :red
18
+ raise "#{cmd} failed" unless system cmd
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,36 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require 'rspec/mocks'
4
+ require 'webmock/rspec'
5
+ require 'pry'
6
+ RSpec::Mocks::setup(Object.new)
7
+
8
+ require 'thegarage/gitx/cli'
9
+
10
+ RSpec.configure do |config|
11
+ config.mock_with :rspec
12
+
13
+ def capture_with_status(stream)
14
+ exit_status = 0
15
+ begin
16
+ stream = stream.to_s
17
+ eval "$#{stream} = StringIO.new"
18
+ begin
19
+ yield
20
+ rescue SystemExit => system_exit # catch any exit calls
21
+ exit_status = system_exit.status
22
+ end
23
+ result = eval("$#{stream}").string
24
+ ensure
25
+ eval("$#{stream} = #{stream.upcase}")
26
+ end
27
+ return result, exit_status
28
+ end
29
+
30
+ def remove_directories(*names)
31
+ project_dir = Pathname.new(Dir.pwd)
32
+ names.each do |name|
33
+ FileUtils.rm_rf(project_dir.join(name)) if FileTest.exists?(project_dir.join(name))
34
+ end
35
+ end
36
+ end