txgh 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +64 -0
- data/lib/ext/zipline/output_stream.rb +62 -0
- data/lib/txgh.rb +53 -0
- data/lib/txgh/app.rb +135 -0
- data/lib/txgh/category_support.rb +31 -0
- data/lib/txgh/config.rb +11 -0
- data/lib/txgh/config/config_pair.rb +36 -0
- data/lib/txgh/config/key_manager.rb +54 -0
- data/lib/txgh/config/provider_instance.rb +20 -0
- data/lib/txgh/config/provider_support.rb +26 -0
- data/lib/txgh/config/providers.rb +9 -0
- data/lib/txgh/config/providers/file_provider.rb +19 -0
- data/lib/txgh/config/providers/git_provider.rb +58 -0
- data/lib/txgh/config/providers/raw_provider.rb +19 -0
- data/lib/txgh/config/tx_config.rb +77 -0
- data/lib/txgh/config/tx_manager.rb +15 -0
- data/lib/txgh/diff_calculator.rb +90 -0
- data/lib/txgh/empty_resource_contents.rb +43 -0
- data/lib/txgh/errors.rb +9 -0
- data/lib/txgh/github_api.rb +83 -0
- data/lib/txgh/github_repo.rb +88 -0
- data/lib/txgh/github_request_auth.rb +28 -0
- data/lib/txgh/handlers.rb +12 -0
- data/lib/txgh/handlers/download_handler.rb +84 -0
- data/lib/txgh/handlers/github.rb +10 -0
- data/lib/txgh/handlers/github/delete_handler.rb +65 -0
- data/lib/txgh/handlers/github/handler.rb +20 -0
- data/lib/txgh/handlers/github/push_handler.rb +108 -0
- data/lib/txgh/handlers/github/request_handler.rb +106 -0
- data/lib/txgh/handlers/response.rb +17 -0
- data/lib/txgh/handlers/stream_response.rb +39 -0
- data/lib/txgh/handlers/tgz_stream_response.rb +41 -0
- data/lib/txgh/handlers/transifex.rb +8 -0
- data/lib/txgh/handlers/transifex/hook_handler.rb +77 -0
- data/lib/txgh/handlers/transifex/request_handler.rb +78 -0
- data/lib/txgh/handlers/triggers.rb +9 -0
- data/lib/txgh/handlers/triggers/handler.rb +66 -0
- data/lib/txgh/handlers/triggers/pull_handler.rb +29 -0
- data/lib/txgh/handlers/triggers/push_handler.rb +21 -0
- data/lib/txgh/handlers/zip_stream_response.rb +21 -0
- data/lib/txgh/merge_calculator.rb +74 -0
- data/lib/txgh/parse_config.rb +24 -0
- data/lib/txgh/resource_committer.rb +39 -0
- data/lib/txgh/resource_contents.rb +118 -0
- data/lib/txgh/resource_downloader.rb +141 -0
- data/lib/txgh/resource_updater.rb +104 -0
- data/lib/txgh/response_helpers.rb +30 -0
- data/lib/txgh/transifex_api.rb +165 -0
- data/lib/txgh/transifex_project.rb +37 -0
- data/lib/txgh/transifex_request_auth.rb +53 -0
- data/lib/txgh/tx_branch_resource.rb +59 -0
- data/lib/txgh/tx_logger.rb +12 -0
- data/lib/txgh/tx_resource.rb +66 -0
- data/lib/txgh/utils.rb +44 -0
- data/lib/txgh/version.rb +3 -0
- data/spec/app_spec.rb +346 -0
- data/spec/category_support_spec.rb +43 -0
- data/spec/config/config_pair_spec.rb +47 -0
- data/spec/config/key_manager_spec.rb +48 -0
- data/spec/config/provider_instance_spec.rb +30 -0
- data/spec/config/provider_support_spec.rb +55 -0
- data/spec/config/tx_config_spec.rb +49 -0
- data/spec/config/tx_manager_spec.rb +57 -0
- data/spec/diff_calculator_spec.rb +90 -0
- data/spec/github_api_spec.rb +148 -0
- data/spec/github_repo_spec.rb +178 -0
- data/spec/github_request_auth_spec.rb +39 -0
- data/spec/handlers/download_handler_spec.rb +81 -0
- data/spec/handlers/github/delete_handler_spec.rb +71 -0
- data/spec/handlers/github/push_handler_spec.rb +76 -0
- data/spec/handlers/tgz_stream_response_spec.rb +59 -0
- data/spec/handlers/transifex/hook_handler_spec.rb +115 -0
- data/spec/handlers/zip_stream_response_spec.rb +58 -0
- data/spec/helpers/github_payload_builder.rb +141 -0
- data/spec/helpers/integration_setup.rb +47 -0
- data/spec/helpers/nil_logger.rb +10 -0
- data/spec/helpers/standard_txgh_setup.rb +92 -0
- data/spec/helpers/test_provider.rb +12 -0
- data/spec/integration/cassettes/github_l10n_hook_endpoint.yml +536 -0
- data/spec/integration/cassettes/pull.yml +47 -0
- data/spec/integration/cassettes/push.yml +544 -0
- data/spec/integration/cassettes/transifex_hook_endpoint.yml +560 -0
- data/spec/integration/config/tx.config +10 -0
- data/spec/integration/hooks_spec.rb +158 -0
- data/spec/integration/payloads/github_postbody.json +161 -0
- data/spec/integration/payloads/github_postbody_l10n.json +136 -0
- data/spec/integration/payloads/github_postbody_release.json +136 -0
- data/spec/integration/triggers_spec.rb +45 -0
- data/spec/merge_calculator_spec.rb +112 -0
- data/spec/parse_config_spec.rb +52 -0
- data/spec/resource_committer_spec.rb +42 -0
- data/spec/resource_contents_spec.rb +212 -0
- data/spec/resource_downloader_spec.rb +205 -0
- data/spec/resource_updater_spec.rb +147 -0
- data/spec/spec_helper.rb +32 -0
- data/spec/transifex_api_spec.rb +345 -0
- data/spec/transifex_project_spec.rb +45 -0
- data/spec/transifex_request_auth_spec.rb +39 -0
- data/spec/tx_branch_resource_spec.rb +99 -0
- data/spec/tx_resource_spec.rb +47 -0
- data/spec/utils_spec.rb +58 -0
- data/txgh.gemspec +29 -0
- metadata +296 -0
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
module Txgh
|
4
|
+
class ResourceUpdater
|
5
|
+
include Txgh::CategorySupport
|
6
|
+
|
7
|
+
attr_reader :project, :repo, :logger
|
8
|
+
|
9
|
+
def initialize(project, repo, logger = nil)
|
10
|
+
@project = project
|
11
|
+
@repo = repo
|
12
|
+
@logger = logger || Logger.new(STDOUT)
|
13
|
+
end
|
14
|
+
|
15
|
+
# For each modified resource, get its content and update the content
|
16
|
+
# in Transifex.
|
17
|
+
def update_resource(tx_resource, commit_sha, categories = {})
|
18
|
+
logger.info('process updated resource')
|
19
|
+
tree_sha = repo.api.get_commit(repo.name, commit_sha)['commit']['tree']['sha']
|
20
|
+
tree = repo.api.tree(repo.name, tree_sha)
|
21
|
+
|
22
|
+
tree['tree'].each do |file|
|
23
|
+
logger.info("process each tree entry: #{file['path']}")
|
24
|
+
|
25
|
+
if tx_resource.source_file == file['path']
|
26
|
+
if repo.upload_diffs?
|
27
|
+
upload_diff(tx_resource, file, categories)
|
28
|
+
else
|
29
|
+
upload_whole(tx_resource, file, categories)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def upload_whole(tx_resource, file, categories)
|
38
|
+
content = contents_of(file['sha'])
|
39
|
+
|
40
|
+
if repo.process_all_branches?
|
41
|
+
upload_by_branch(tx_resource, content, categories)
|
42
|
+
else
|
43
|
+
upload(tx_resource, content)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def upload_diff(tx_resource, file, categories)
|
48
|
+
if content = diff_content(tx_resource, file)
|
49
|
+
upload_by_branch(tx_resource, content, categories)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def diff_content(tx_resource, file)
|
54
|
+
diff = head_content(tx_resource, file).diff(
|
55
|
+
diff_point_content(tx_resource, file)
|
56
|
+
)
|
57
|
+
|
58
|
+
diff.to_s unless diff.empty?
|
59
|
+
end
|
60
|
+
|
61
|
+
def head_content(tx_resource, file)
|
62
|
+
ResourceContents.from_string(tx_resource, contents_of(file['sha']))
|
63
|
+
end
|
64
|
+
|
65
|
+
def diff_point_content(tx_resource, file)
|
66
|
+
raw_content = repo.api.download(repo.name, file['path'], repo.diff_point)
|
67
|
+
ResourceContents.from_string(tx_resource, raw_content)
|
68
|
+
end
|
69
|
+
|
70
|
+
def upload(tx_resource, content)
|
71
|
+
project.api.create_or_update(tx_resource, content)
|
72
|
+
end
|
73
|
+
|
74
|
+
def upload_by_branch(tx_resource, content, additional_categories)
|
75
|
+
resource_exists = project.api.resource_exists?(tx_resource)
|
76
|
+
categories = resource_exists ? categories_for(tx_resource) : {}
|
77
|
+
categories.merge!(additional_categories)
|
78
|
+
categories['branch'] ||= tx_resource.branch
|
79
|
+
categories = serialize_categories(categories)
|
80
|
+
|
81
|
+
if resource_exists
|
82
|
+
project.api.update_details(tx_resource, categories: categories)
|
83
|
+
project.api.update_content(tx_resource, content)
|
84
|
+
else
|
85
|
+
project.api.create(tx_resource, content, categories)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def categories_for(tx_resource)
|
90
|
+
resource = project.api.get_resource(*tx_resource.slugs)
|
91
|
+
deserialize_categories(Array(resource['categories']))
|
92
|
+
end
|
93
|
+
|
94
|
+
def contents_of(sha)
|
95
|
+
blob = repo.api.blob(repo.name, sha)
|
96
|
+
|
97
|
+
if blob['encoding'] == 'utf-8'
|
98
|
+
blob['content']
|
99
|
+
else
|
100
|
+
Base64.decode64(blob['content'])
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Txgh
|
2
|
+
module ResponseHelpers
|
3
|
+
private
|
4
|
+
|
5
|
+
def respond_with(status, body, e = nil)
|
6
|
+
Txgh::Handlers::Response.new(status, body, e)
|
7
|
+
end
|
8
|
+
|
9
|
+
def respond_with_success(status, body)
|
10
|
+
respond_with(status, data(body))
|
11
|
+
end
|
12
|
+
|
13
|
+
def respond_with_error(status, message, e = nil)
|
14
|
+
respond_with(status, error(message), e)
|
15
|
+
end
|
16
|
+
|
17
|
+
def error(message)
|
18
|
+
[{ error: message }]
|
19
|
+
end
|
20
|
+
|
21
|
+
def data(body)
|
22
|
+
{ data: body }
|
23
|
+
end
|
24
|
+
|
25
|
+
# includes these methods in the singleton class as well
|
26
|
+
def self.included(base)
|
27
|
+
base.extend(self)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,165 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'faraday_middleware'
|
3
|
+
require 'json'
|
4
|
+
require 'set'
|
5
|
+
|
6
|
+
module Txgh
|
7
|
+
class TransifexApi
|
8
|
+
API_ROOT = '/api/2'
|
9
|
+
|
10
|
+
class << self
|
11
|
+
def create_from_credentials(username, password)
|
12
|
+
connection = Faraday.new(url: 'https://www.transifex.com') do |faraday|
|
13
|
+
faraday.request(:multipart)
|
14
|
+
faraday.request(:json)
|
15
|
+
faraday.request(:url_encoded)
|
16
|
+
|
17
|
+
faraday.response(:logger)
|
18
|
+
faraday.use(FaradayMiddleware::FollowRedirects)
|
19
|
+
faraday.adapter(Faraday.default_adapter)
|
20
|
+
end
|
21
|
+
|
22
|
+
connection.basic_auth(username, password)
|
23
|
+
connection.headers.update(Accept: 'application/json')
|
24
|
+
create_from_connection(connection)
|
25
|
+
end
|
26
|
+
|
27
|
+
def create_from_connection(connection)
|
28
|
+
new(connection)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
attr_reader :connection
|
33
|
+
|
34
|
+
def initialize(connection)
|
35
|
+
@connection = connection
|
36
|
+
end
|
37
|
+
|
38
|
+
def create_or_update(tx_resource, content, categories = [])
|
39
|
+
if resource_exists?(tx_resource)
|
40
|
+
resource = get_resource(*tx_resource.slugs)
|
41
|
+
new_categories = Set.new(resource['categories'])
|
42
|
+
new_categories.merge(categories)
|
43
|
+
|
44
|
+
# update details first so new content is always tagged
|
45
|
+
update_details(tx_resource, categories: new_categories.to_a)
|
46
|
+
update_content(tx_resource, content)
|
47
|
+
else
|
48
|
+
create(tx_resource, content, categories)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def create(tx_resource, content, categories = [])
|
53
|
+
payload = {
|
54
|
+
slug: tx_resource.resource_slug,
|
55
|
+
name: tx_resource.source_file,
|
56
|
+
i18n_type: tx_resource.type,
|
57
|
+
categories: CategorySupport.join_categories(categories.uniq),
|
58
|
+
content: get_content_io(tx_resource, content)
|
59
|
+
}
|
60
|
+
|
61
|
+
url = "#{API_ROOT}/project/#{tx_resource.project_slug}/resources/"
|
62
|
+
response = connection.post(url, payload)
|
63
|
+
raise_error!(response)
|
64
|
+
end
|
65
|
+
|
66
|
+
def delete(tx_resource)
|
67
|
+
url = "#{API_ROOT}/project/#{tx_resource.project_slug}/resource/#{tx_resource.resource_slug}/"
|
68
|
+
connection.delete(url)
|
69
|
+
end
|
70
|
+
|
71
|
+
def update_content(tx_resource, content)
|
72
|
+
content_io = get_content_io(tx_resource, content)
|
73
|
+
payload = { content: content_io }
|
74
|
+
url = "#{API_ROOT}/project/#{tx_resource.project_slug}/resource/#{tx_resource.resource_slug}/content/"
|
75
|
+
response = connection.put(url, payload)
|
76
|
+
raise_error!(response)
|
77
|
+
end
|
78
|
+
|
79
|
+
def update_details(tx_resource, details = {})
|
80
|
+
url = "#{API_ROOT}/project/#{tx_resource.project_slug}/resource/#{tx_resource.resource_slug}/"
|
81
|
+
response = connection.put(url, details)
|
82
|
+
raise_error!(response)
|
83
|
+
end
|
84
|
+
|
85
|
+
def resource_exists?(tx_resource)
|
86
|
+
project = tx_resource.project_slug
|
87
|
+
slug = tx_resource.resource_slug
|
88
|
+
response = connection.get("#{API_ROOT}/project/#{project}/resource/#{slug}/")
|
89
|
+
response.status == 200
|
90
|
+
end
|
91
|
+
|
92
|
+
def download(tx_resource, lang)
|
93
|
+
project_slug = tx_resource.project_slug
|
94
|
+
resource_slug = tx_resource.resource_slug
|
95
|
+
response = connection.get(
|
96
|
+
"#{API_ROOT}/project/#{project_slug}/resource/#{resource_slug}/translation/#{lang}/"
|
97
|
+
)
|
98
|
+
|
99
|
+
raise_error!(response)
|
100
|
+
|
101
|
+
json_data = JSON.parse(response.body)
|
102
|
+
json_data['content']
|
103
|
+
end
|
104
|
+
|
105
|
+
def get_resource(project_slug, resource_slug)
|
106
|
+
url = "#{API_ROOT}/project/#{project_slug}/resource/#{resource_slug}/"
|
107
|
+
response = connection.get(url)
|
108
|
+
raise_error!(response)
|
109
|
+
JSON.parse(response.body)
|
110
|
+
end
|
111
|
+
|
112
|
+
def get_resources(project_slug)
|
113
|
+
url = "#{API_ROOT}/project/#{project_slug}/resources/"
|
114
|
+
response = connection.get(url)
|
115
|
+
raise_error!(response)
|
116
|
+
JSON.parse(response.body)
|
117
|
+
end
|
118
|
+
|
119
|
+
def get_languages(project_slug)
|
120
|
+
url = "#{API_ROOT}/project/#{project_slug}/languages/"
|
121
|
+
response = connection.get(url)
|
122
|
+
raise_error!(response)
|
123
|
+
JSON.parse(response.body)
|
124
|
+
end
|
125
|
+
|
126
|
+
def get_project(project_slug)
|
127
|
+
url = "#{API_ROOT}/project/#{project_slug}/"
|
128
|
+
response = connection.get(url)
|
129
|
+
raise_error!(response)
|
130
|
+
JSON.parse(response.body)
|
131
|
+
end
|
132
|
+
|
133
|
+
def get_formats
|
134
|
+
url = "#{API_ROOT}/formats/"
|
135
|
+
response = connection.get(url)
|
136
|
+
raise_error!(response)
|
137
|
+
JSON.parse(response.body)
|
138
|
+
end
|
139
|
+
|
140
|
+
private
|
141
|
+
|
142
|
+
def get_content_io(tx_resource, content)
|
143
|
+
content_io = StringIO::new(content)
|
144
|
+
content_io.set_encoding(Encoding::UTF_8.name)
|
145
|
+
Faraday::UploadIO.new(
|
146
|
+
content_io, 'application/octet-stream', tx_resource.source_file
|
147
|
+
)
|
148
|
+
end
|
149
|
+
|
150
|
+
def raise_error!(response)
|
151
|
+
case response.status
|
152
|
+
when 401
|
153
|
+
raise TransifexUnauthorizedError
|
154
|
+
when 404
|
155
|
+
raise TransifexNotFoundError
|
156
|
+
else
|
157
|
+
if (response.status / 100) != 2
|
158
|
+
raise TransifexApiError,
|
159
|
+
"Failed Transifex API call - returned status code: #{response.status}, body: #{response.body}"
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Txgh
|
2
|
+
class TransifexProject
|
3
|
+
attr_reader :config, :api
|
4
|
+
|
5
|
+
def initialize(config, api)
|
6
|
+
@config = config
|
7
|
+
@api = api
|
8
|
+
end
|
9
|
+
|
10
|
+
def name
|
11
|
+
config['name']
|
12
|
+
end
|
13
|
+
|
14
|
+
def webhook_secret
|
15
|
+
config['webhook_secret']
|
16
|
+
end
|
17
|
+
|
18
|
+
def protected_branches
|
19
|
+
@protected_branches ||=
|
20
|
+
(config['protected_branches'] || '').split(',').map do |branch|
|
21
|
+
Utils.absolute_branch(branch.strip)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def webhook_protected?
|
26
|
+
!(webhook_secret || '').empty?
|
27
|
+
end
|
28
|
+
|
29
|
+
def auto_delete_resources?
|
30
|
+
(config['auto_delete_resources'] || '').downcase == 'true'
|
31
|
+
end
|
32
|
+
|
33
|
+
def tx_config_uri
|
34
|
+
config['tx_config']
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
|
3
|
+
module Txgh
|
4
|
+
class TransifexRequestAuth
|
5
|
+
HMAC_DIGEST = OpenSSL::Digest.new('sha1')
|
6
|
+
RACK_HEADER = 'HTTP_X_TX_SIGNATURE'
|
7
|
+
TRANSIFEX_HEADER = 'X-TX-Signature'
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def authentic_request?(request, secret)
|
11
|
+
request.body.rewind
|
12
|
+
expected_signature = header_value(request.body.read, secret)
|
13
|
+
actual_signature = request.env[RACK_HEADER]
|
14
|
+
actual_signature == expected_signature
|
15
|
+
end
|
16
|
+
|
17
|
+
def header_value(content, secret)
|
18
|
+
digest(transform(content), secret)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
# In order to generate a correct HMAC hash, the request body must be
|
24
|
+
# parsed and made to look like a python map. If you're thinking that's
|
25
|
+
# weird, you're correct, but it's apparently expected behavior.
|
26
|
+
def transform(content)
|
27
|
+
params = URI.decode_www_form(content)
|
28
|
+
|
29
|
+
params = params.map do |key, val|
|
30
|
+
key = "'#{key}'"
|
31
|
+
val = interpret_val(val)
|
32
|
+
"#{key}: #{val}"
|
33
|
+
end
|
34
|
+
|
35
|
+
"{#{params.join(', ')}}"
|
36
|
+
end
|
37
|
+
|
38
|
+
def interpret_val(val)
|
39
|
+
if val =~ /\A[\d]+\z/
|
40
|
+
val
|
41
|
+
else
|
42
|
+
"u'#{val}'"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def digest(content, secret)
|
47
|
+
Base64.encode64(
|
48
|
+
OpenSSL::HMAC.digest(HMAC_DIGEST, secret, content)
|
49
|
+
).strip
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Txgh
|
4
|
+
class TxBranchResource
|
5
|
+
extend Forwardable
|
6
|
+
|
7
|
+
def_delegators :@resource, *[
|
8
|
+
:project_slug, :type, :source_lang, :source_file, :L10N_resource_slug,
|
9
|
+
:translation_file, :lang_map, :translation_path, :original_resource_slug,
|
10
|
+
:to_h, :to_api_h
|
11
|
+
]
|
12
|
+
|
13
|
+
attr_reader :resource, :branch
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def find(tx_config, resource_slug, branch)
|
17
|
+
resource_slug = deslugify(resource_slug, branch)
|
18
|
+
resource = tx_config.resource(resource_slug)
|
19
|
+
new(resource, branch) if resource
|
20
|
+
end
|
21
|
+
|
22
|
+
def deslugify(resource_slug, branch)
|
23
|
+
suffix = "-#{Utils.slugify(branch)}"
|
24
|
+
|
25
|
+
if resource_slug.end_with?(suffix)
|
26
|
+
resource_slug.chomp(suffix)
|
27
|
+
else
|
28
|
+
resource_slug
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def initialize(resource, branch)
|
34
|
+
@resource = resource
|
35
|
+
@branch = branch
|
36
|
+
end
|
37
|
+
|
38
|
+
def resource_slug
|
39
|
+
"#{resource.resource_slug}-#{slugified_branch}"
|
40
|
+
end
|
41
|
+
|
42
|
+
def slugs
|
43
|
+
[project_slug, resource_slug]
|
44
|
+
end
|
45
|
+
|
46
|
+
def to_h
|
47
|
+
resource.to_h.merge(
|
48
|
+
project_slug: project_slug,
|
49
|
+
resource_slug: resource_slug
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def slugified_branch
|
56
|
+
Utils.slugify(branch)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|