github-pivotal-flow 0.0.1

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,33 @@
1
+ # The class that encapsulates finishing a Pivotal Tracker Story
2
+ module GithubPivotalFlow
3
+ class Finish < Command
4
+
5
+ # Finishes a Pivotal Tracker story
6
+ def run!
7
+ story = @configuration.story(@project)
8
+ story.can_merge?
9
+ commit_message = options[:commit_message]
10
+ if story.release?
11
+ story.merge_release!(commit_message, @options)
12
+ else
13
+ story.merge_to_root!(commit_message, @options)
14
+ end
15
+ return 0
16
+ end
17
+
18
+ private
19
+
20
+ def parse_argv(*args)
21
+ OptionParser.new do |opts|
22
+ opts.banner = "Usage: git finish [options]"
23
+ opts.on("-t", "--api-token=", "Pivotal Tracker API key") { |k| options[:api_token] = k }
24
+ opts.on("-p", "--project-id=", "Pivotal Tracker project id") { |p| options[:project_id] = p }
25
+ opts.on("-n", "--full-name=", "Your Pivotal Tracker full name") { |n| options[:full_name] = n }
26
+ opts.on("-m", "--message=", "Specify a commit message") { |m| options[:commit_message] = m }
27
+
28
+ opts.on("--no-complete", "Do not mark the story completed on Pivotal Tracker") { options[:no_complete] = true }
29
+ opts.on_tail("-h", "--help", "This usage guide") { put opts.to_s; exit 0 }
30
+ end.parse!(args)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,150 @@
1
+ module GithubPivotalFlow
2
+ class Git
3
+ def self.current_branch
4
+ exec('git branch').scan(/\* (.*)/)[0][0]
5
+ end
6
+
7
+ def self.checkout(branch_name)
8
+ exec "git checkout --quiet #{branch_name}" unless branch_name == self.current_branch
9
+ end
10
+
11
+ def self.pull(ref, origin)
12
+ exec "git pull --quiet #{[origin, ref].compact.join(' ')}"
13
+ end
14
+
15
+ def self.get_remote
16
+ remote = get_config('remote', :branch).strip
17
+ return exec('git remote').strip if remote.blank?
18
+ remote
19
+ end
20
+
21
+ def self.pull_remote(branch_name = nil)
22
+ prev_branch = self.current_branch
23
+ branch_name ||= self.current_branch
24
+ self.checkout(branch_name)
25
+ remote = self.get_remote
26
+ self.pull(branch_name, remote) unless remote.blank?
27
+ self.checkout(prev_branch)
28
+ end
29
+
30
+ def self.create_branch(branch_name, start_point = nil, options = {})
31
+ return if branch_exists?(branch_name)
32
+ exec "git branch --quiet #{[branch_name, start_point].compact.join(' ')}"
33
+ puts 'OK'
34
+ end
35
+
36
+ def self.branch_exists?(name)
37
+ system "git show-ref --quiet --verify refs/heads/#{name}"
38
+ end
39
+
40
+ def self.ensure_branch_exists(branch_name)
41
+ return if branch_name == current_branch || self.branch_exists?(branch_name)
42
+ exec "git branch --quiet #{branch_name}", false
43
+ end
44
+
45
+ def self.merge(branch_name, options = {})
46
+ command = "git merge --quiet"
47
+ command << " --no-ff" if options[:no_ff]
48
+ command << " -m \"#{options[:commit_message]}\"" unless options[:commit_message].blank?
49
+ exec "#{command} #{branch_name}"
50
+ puts 'OK'
51
+ end
52
+
53
+ def self.publish(branch_name)
54
+ branch_name ||= self.current_branch
55
+ exec "git checkout --quiet #{branch_name}" unless branch_name == self.current_branch
56
+ exec "git push #{self.get_remote} #{branch_name}"
57
+ end
58
+
59
+ def self.commit(options = {})
60
+ command = "git commit --quiet"
61
+ command << " --allow-empty" if options[:allow_empty]
62
+ command << " -m \"#{options[:commit_message]}\"" unless options[:commit_message].blank?
63
+ exec command
64
+ end
65
+
66
+ def self.tag(tag_name)
67
+ exec "git tag #{tag_name}"
68
+ end
69
+
70
+ def self.delete_branch(branch_name, options = {})
71
+ command = "git branch"
72
+ command << (options[:force] ? " -D" : " -d")
73
+ exec "#{command} #{branch_name}"
74
+ puts 'OK'
75
+ end
76
+
77
+ def self.delete_remote_branch(branch_name)
78
+ exec "git push #{Git.get_remote} --delete #{branch_name}"
79
+ end
80
+
81
+ def self.push(*refs)
82
+ remote = self.get_remote
83
+
84
+ print "Pushing to #{remote}... "
85
+ exec "git push --quiet #{remote} " + refs.join(' ')
86
+ puts 'OK'
87
+ end
88
+
89
+ def self.push_tags
90
+ exec "git push --tags #{self.get_remote}"
91
+ end
92
+
93
+ def self.get_config(key, scope = :inherited)
94
+ if :branch == scope
95
+ exec("git config branch.#{self.current_branch}.#{key}", false).strip
96
+ elsif :inherited == scope
97
+ exec("git config #{key}", false).strip
98
+ else
99
+ raise "Unable to get Git configuration for scope '#{scope}'"
100
+ end
101
+ end
102
+
103
+ def self.set_config(key, value, scope = :local)
104
+ if :branch == scope
105
+ exec "git config --local branch.#{self.current_branch}.#{key} #{value}"
106
+ elsif :global == scope
107
+ exec "git config --global #{key} #{value}"
108
+ elsif :local == scope
109
+ exec "git config --local #{key} #{value}"
110
+ else
111
+ raise "Unable to set Git configuration for scope '#{scope}'"
112
+ end
113
+ end
114
+
115
+ def self.repository_root
116
+ repository_root = Dir.pwd
117
+
118
+ until Dir.entries(repository_root).any? { |child| File.directory?(child) && (child =~ /^.git$/) }
119
+ next_repository_root = File.expand_path('..', repository_root)
120
+ abort('Current working directory is not in a Git repository') unless repository_root != next_repository_root
121
+ repository_root = next_repository_root
122
+ end
123
+ repository_root
124
+ end
125
+
126
+ def self.add_hook(name, source, overwrite = false)
127
+ hooks_directory = File.join repository_root, '.git', 'hooks'
128
+ hook = File.join hooks_directory, name
129
+
130
+ if overwrite || !File.exist?(hook)
131
+ print "Creating Git hook #{name}... "
132
+
133
+ FileUtils.mkdir_p hooks_directory
134
+ File.open(source, 'r') do |input|
135
+ File.open(hook, 'w') do |output|
136
+ output.write(input.read)
137
+ output.chmod(0755)
138
+ end
139
+ end
140
+
141
+ puts 'OK'
142
+ end
143
+ end
144
+
145
+ private
146
+ def self.exec(command, abort_on_failure = true)
147
+ return Shell.exec(command, abort_on_failure)
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,241 @@
1
+ module GithubPivotalFlow
2
+ # Client for the GitHub v3 API.
3
+ class GitHubAPI
4
+ attr_reader :config, :oauth_app_url
5
+
6
+ def initialize config, options
7
+ @config = config
8
+ @oauth_app_url = options.fetch(:app_url)
9
+ end
10
+
11
+ # Fake exception type for net/http exception handling.
12
+ # Necessary because net/http may or may not be loaded at the time.
13
+ module Exceptions
14
+ def self.===(exception)
15
+ exception.class.ancestors.map {|a| a.to_s }.include? 'Net::HTTPExceptions'
16
+ end
17
+ end
18
+
19
+ def api_host host
20
+ host = host.downcase
21
+ 'github.com' == host ? 'api.github.com' : host
22
+ end
23
+
24
+ def username_via_auth_dance host
25
+ host = api_host(host)
26
+ config.github_username(host) do
27
+ if block_given?
28
+ yield
29
+ else
30
+ res = get("https://%s/user" % host)
31
+ res.error! unless res.success?
32
+ config.github_username = res.data['login']
33
+ end
34
+ end
35
+ end
36
+
37
+ # Returns parsed data from the new pull request.
38
+ def create_pullrequest options
39
+ project = options.fetch(:project)
40
+ params = {
41
+ :base => options.fetch(:base),
42
+ :head => options.fetch(:head)
43
+ }
44
+
45
+ if options[:issue]
46
+ params[:issue] = options[:issue]
47
+ else
48
+ params[:title] = options[:title] if options[:title]
49
+ params[:body] = options[:body] if options[:body]
50
+ end
51
+
52
+ res = post "https://%s/repos/%s/%s/pulls" %
53
+ [api_host(project.host), project.owner, project.name], params
54
+
55
+ res.error! unless res.success?
56
+ res.data
57
+ end
58
+
59
+ def statuses project, sha
60
+ res = get "https://%s/repos/%s/%s/statuses/%s" %
61
+ [api_host(project.host), project.owner, project.name, sha]
62
+
63
+ res.error! unless res.success?
64
+ res.data
65
+ end
66
+
67
+ module HttpMethods
68
+ # Decorator for Net::HTTPResponse
69
+ module ResponseMethods
70
+ def status() code.to_i end
71
+ def data?() content_type =~ /\bjson\b/ end
72
+ def data() @data ||= JSON.parse(body) end
73
+ def error_message?() data? and data['errors'] || data['message'] end
74
+ def error_message() error_sentences || data['message'] end
75
+ def success?() Net::HTTPSuccess === self end
76
+ def error_sentences
77
+ data['errors'].map do |err|
78
+ case err['code']
79
+ when 'custom' then err['message']
80
+ when 'missing_field'
81
+ %(Missing field: "%s") % err['field']
82
+ when 'invalid'
83
+ %(Invalid value for "%s": "%s") % [ err['field'], err['value'] ]
84
+ when 'unauthorized'
85
+ %(Not allowed to change field "%s") % err['field']
86
+ end
87
+ end.compact if data['errors']
88
+ end
89
+ end
90
+
91
+ def get url, &block
92
+ perform_request url, :Get, &block
93
+ end
94
+
95
+ def post url, params = nil
96
+ perform_request url, :Post do |req|
97
+ if params
98
+ req.body = JSON.dump params
99
+ req['Content-Type'] = 'application/json;charset=utf-8'
100
+ end
101
+ yield req if block_given?
102
+ req['Content-Length'] = byte_size req.body
103
+ end
104
+ end
105
+
106
+ def byte_size str
107
+ if str.respond_to? :bytesize then str.bytesize
108
+ elsif str.respond_to? :length then str.length
109
+ else 0
110
+ end
111
+ end
112
+
113
+ def post_form url, params
114
+ post(url) {|req| req.set_form_data params }
115
+ end
116
+
117
+ def perform_request url, type
118
+ url = URI.parse url unless url.respond_to? :host
119
+
120
+ require 'net/https'
121
+ req = Net::HTTP.const_get(type).new request_uri(url)
122
+ # TODO: better naming?
123
+ http = configure_connection(req, url) do |host_url|
124
+ create_connection host_url
125
+ end
126
+
127
+ req['User-Agent'] = "Hub 1.11.1"
128
+ apply_authentication(req, url)
129
+ yield req if block_given?
130
+ finalize_request(req, url)
131
+
132
+ begin
133
+ res = http.start { http.request(req) }
134
+ res.extend ResponseMethods
135
+ return res
136
+ rescue SocketError => err
137
+ raise Context::FatalError, "error with #{type.to_s.upcase} #{url} (#{err.message})"
138
+ end
139
+ end
140
+
141
+ def request_uri url
142
+ str = url.request_uri
143
+ str = '/api/v3' << str if url.host != 'api.github.com' && url.host != 'gist.github.com'
144
+ str
145
+ end
146
+
147
+ def configure_connection req, url
148
+ if ENV['HUB_TEST_HOST']
149
+ req['Host'] = url.host
150
+ url = url.dup
151
+ url.scheme = 'http'
152
+ url.host, test_port = ENV['HUB_TEST_HOST'].split(':')
153
+ url.port = test_port.to_i if test_port
154
+ end
155
+ yield url
156
+ end
157
+
158
+ def apply_authentication req, url
159
+ user = url.user ? CGI.unescape(url.user) : config.github_username(url.host)
160
+ pass = config.github_password(url.host, user)
161
+ req.basic_auth user, pass
162
+ end
163
+
164
+ def finalize_request(req, url)
165
+ if !req['Accept'] || req['Accept'] == '*/*'
166
+ req['Accept'] = 'application/vnd.github.v3+json'
167
+ end
168
+ end
169
+
170
+ def create_connection url
171
+ use_ssl = 'https' == url.scheme
172
+
173
+ proxy_args = []
174
+ if proxy = config.proxy_uri(use_ssl)
175
+ proxy_args << proxy.host << proxy.port
176
+ if proxy.userinfo
177
+ # proxy user + password
178
+ proxy_args.concat proxy.userinfo.split(':', 2).map {|a| CGI.unescape a }
179
+ end
180
+ end
181
+
182
+ http = Net::HTTP.new(url.host, url.port, *proxy_args)
183
+
184
+ if http.use_ssl = use_ssl
185
+ # FIXME: enable SSL peer verification!
186
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
187
+ end
188
+ return http
189
+ end
190
+ end
191
+
192
+ module OAuth
193
+ def apply_authentication req, url
194
+ if req.path =~ %r{^(/api/v3)?/authorizations$}
195
+ super
196
+ else
197
+ user = url.user ? CGI.unescape(url.user) : config.github_username(url.host)
198
+ token = config.github_api_token(url.host, user) {
199
+ obtain_oauth_token url.host, user
200
+ }
201
+ req['Authorization'] = "token #{token}"
202
+ end
203
+ end
204
+
205
+ def obtain_oauth_token host, user, two_factor_code = nil
206
+ auth_url = URI.parse("https://%s@%s/authorizations" % [CGI.escape(user), host])
207
+
208
+ # dummy request to trigger a 2FA SMS since a HTTP GET won't do it
209
+ post(auth_url) if !two_factor_code
210
+
211
+ # first try to fetch existing authorization
212
+ res = get(auth_url) do |req|
213
+ req['X-GitHub-OTP'] = two_factor_code if two_factor_code
214
+ end
215
+ unless res.success?
216
+ if !two_factor_code && res['X-GitHub-OTP'].to_s.include?('required')
217
+ two_factor_code = config.ask_auth_code
218
+ return obtain_oauth_token(host, user, two_factor_code)
219
+ else
220
+ res.error!
221
+ end
222
+ end
223
+
224
+ if found = res.data.find {|auth| auth['app']['url'] == oauth_app_url }
225
+ found['token']
226
+ else
227
+ # create a new authorization
228
+ res = post auth_url,
229
+ :scopes => %w[repo], :note => 'github-pivotal-flow', :note_url => oauth_app_url do |req|
230
+ req['X-GitHub-OTP'] = two_factor_code if two_factor_code
231
+ end
232
+ res.error! unless res.success?
233
+ res.data['token']
234
+ end
235
+ end
236
+ end
237
+
238
+ include HttpMethods
239
+ include OAuth
240
+ end
241
+ end
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ CURRENT_BRANCH=$(git branch | grep "*" | sed "s/* //")
3
+ STORY_ID=$(git config branch.$CURRENT_BRANCH.pivotal-story-id)
4
+
5
+ if [[ $2 != "commit" && -n $STORY_ID ]]; then
6
+ ORIG_MSG_FILE="$1"
7
+ TEMP=$(mktemp /tmp/git-XXXXX)
8
+
9
+ (printf "\n\n[#$STORY_ID]" ; cat "$1") > "$TEMP"
10
+ cat "$TEMP" > "$ORIG_MSG_FILE"
11
+ fi
@@ -0,0 +1,23 @@
1
+ module GithubPivotalFlow
2
+ class Project < Struct.new(:owner, :name, :host)
3
+ def self.from_url(url)
4
+ _, owner, name = url.path.split('/', 4)
5
+ self.new(owner, name.sub(/\.git$/, ''), url.host)
6
+ end
7
+
8
+ def initialize(*args)
9
+ super
10
+ self.name = self.name.tr(' ', '-')
11
+ self.host ||= 'github.com'
12
+ self.host = host.sub(/^ssh\./i, '') if 'ssh.github.com' == host.downcase
13
+ end
14
+
15
+ def name_with_owner
16
+ "#{owner}/#{name}"
17
+ end
18
+
19
+ def ==(other)
20
+ name_with_owner == other.name_with_owner
21
+ end
22
+ end
23
+ end