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.
- checksums.yaml +7 -0
- data/LICENSE +14 -0
- data/README.md +183 -0
- data/bin/git-finish +6 -0
- data/bin/git-start +6 -0
- data/lib/core_ext/object/blank.rb +105 -0
- data/lib/github_pivotal_flow.rb +31 -0
- data/lib/github_pivotal_flow/command.rb +57 -0
- data/lib/github_pivotal_flow/configuration.rb +251 -0
- data/lib/github_pivotal_flow/finish.rb +33 -0
- data/lib/github_pivotal_flow/git.rb +150 -0
- data/lib/github_pivotal_flow/github_api.rb +241 -0
- data/lib/github_pivotal_flow/prepare-commit-msg.sh +11 -0
- data/lib/github_pivotal_flow/project.rb +23 -0
- data/lib/github_pivotal_flow/shell.rb +21 -0
- data/lib/github_pivotal_flow/start.rb +40 -0
- data/lib/github_pivotal_flow/story.rb +278 -0
- data/spec/github_pivotal_flow/configuration_spec.rb +70 -0
- data/spec/github_pivotal_flow/finish_spec.rb +37 -0
- data/spec/github_pivotal_flow/git_spec.rb +167 -0
- data/spec/github_pivotal_flow/shell_spec.rb +36 -0
- data/spec/github_pivotal_flow/start_spec.rb +41 -0
- data/spec/github_pivotal_flow/story_spec.rb +125 -0
- metadata +186 -0
@@ -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
|