Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
heptapod
heptapod
Commits
df2cba6898b2
Commit
b64a37c4
authored
May 04, 2017
by
Alfredo Sumaran
Committed by
Phil Hughes
May 04, 2017
Browse files
Allow to create new branch and empty WIP merge request from issue page
parent
8a1dc8500c21
Changes
21
Hide whitespace changes
Inline
Side-by-side
app/assets/javascripts/create_merge_request_dropdown.js
0 → 100644
View file @
df2cba68
/* eslint-disable no-new */
/* global Flash */
import
DropLab
from
'
./droplab/drop_lab
'
;
import
ISetter
from
'
./droplab/plugins/input_setter
'
;
// Todo: Remove this when fixing issue in input_setter plugin
const
InputSetter
=
Object
.
assign
({},
ISetter
);
const
CREATE_MERGE_REQUEST
=
'
create-mr
'
;
const
CREATE_BRANCH
=
'
create-branch
'
;
export
default
class
CreateMergeRequestDropdown
{
constructor
(
wrapperEl
)
{
this
.
wrapperEl
=
wrapperEl
;
this
.
createMergeRequestButton
=
this
.
wrapperEl
.
querySelector
(
'
.js-create-merge-request
'
);
this
.
dropdownToggle
=
this
.
wrapperEl
.
querySelector
(
'
.js-dropdown-toggle
'
);
this
.
dropdownList
=
this
.
wrapperEl
.
querySelector
(
'
.dropdown-menu
'
);
this
.
availableButton
=
this
.
wrapperEl
.
querySelector
(
'
.available
'
);
this
.
unavailableButton
=
this
.
wrapperEl
.
querySelector
(
'
.unavailable
'
);
this
.
unavailableButtonArrow
=
this
.
unavailableButton
.
querySelector
(
'
.fa
'
);
this
.
unavailableButtonText
=
this
.
unavailableButton
.
querySelector
(
'
.text
'
);
this
.
createBranchPath
=
this
.
wrapperEl
.
dataset
.
createBranchPath
;
this
.
canCreatePath
=
this
.
wrapperEl
.
dataset
.
canCreatePath
;
this
.
createMrPath
=
this
.
wrapperEl
.
dataset
.
createMrPath
;
this
.
droplabInitialized
=
false
;
this
.
isCreatingMergeRequest
=
false
;
this
.
mergeRequestCreated
=
false
;
this
.
isCreatingBranch
=
false
;
this
.
branchCreated
=
false
;
this
.
init
();
}
init
()
{
this
.
checkAbilityToCreateBranch
();
}
available
()
{
this
.
availableButton
.
classList
.
remove
(
'
hide
'
);
this
.
unavailableButton
.
classList
.
add
(
'
hide
'
);
}
unavailable
()
{
this
.
availableButton
.
classList
.
add
(
'
hide
'
);
this
.
unavailableButton
.
classList
.
remove
(
'
hide
'
);
}
enable
()
{
this
.
createMergeRequestButton
.
classList
.
remove
(
'
disabled
'
);
this
.
createMergeRequestButton
.
removeAttribute
(
'
disabled
'
);
this
.
dropdownToggle
.
classList
.
remove
(
'
disabled
'
);
this
.
dropdownToggle
.
removeAttribute
(
'
disabled
'
);
}
disable
()
{
this
.
createMergeRequestButton
.
classList
.
add
(
'
disabled
'
);
this
.
createMergeRequestButton
.
setAttribute
(
'
disabled
'
,
'
disabled
'
);
this
.
dropdownToggle
.
classList
.
add
(
'
disabled
'
);
this
.
dropdownToggle
.
setAttribute
(
'
disabled
'
,
'
disabled
'
);
}
hide
()
{
this
.
wrapperEl
.
classList
.
add
(
'
hide
'
);
}
setUnavailableButtonState
(
isLoading
=
true
)
{
if
(
isLoading
)
{
this
.
unavailableButtonArrow
.
classList
.
add
(
'
fa-spinner
'
,
'
fa-spin
'
);
this
.
unavailableButtonArrow
.
classList
.
remove
(
'
fa-exclamation-triangle
'
);
this
.
unavailableButtonText
.
textContent
=
'
Checking branch availability…
'
;
}
else
{
this
.
unavailableButtonArrow
.
classList
.
remove
(
'
fa-spinner
'
,
'
fa-spin
'
);
this
.
unavailableButtonArrow
.
classList
.
add
(
'
fa-exclamation-triangle
'
);
this
.
unavailableButtonText
.
textContent
=
'
New branch unavailable
'
;
}
}
checkAbilityToCreateBranch
()
{
return
$
.
ajax
({
type
:
'
GET
'
,
dataType
:
'
json
'
,
url
:
this
.
canCreatePath
,
beforeSend
:
()
=>
this
.
setUnavailableButtonState
(),
})
.
done
((
data
)
=>
{
this
.
setUnavailableButtonState
(
false
);
if
(
data
.
can_create_branch
)
{
this
.
available
();
this
.
enable
();
if
(
!
this
.
droplabInitialized
)
{
this
.
droplabInitialized
=
true
;
this
.
initDroplab
();
this
.
bindEvents
();
}
}
else
if
(
data
.
has_related_branch
)
{
this
.
hide
();
}
}).
fail
(()
=>
{
this
.
unavailable
();
this
.
disable
();
new
Flash
(
'
Failed to check if a new branch can be created.
'
);
});
}
initDroplab
()
{
this
.
droplab
=
new
DropLab
();
this
.
droplab
.
init
(
this
.
dropdownToggle
,
this
.
dropdownList
,
[
InputSetter
],
this
.
getDroplabConfig
());
}
getDroplabConfig
()
{
return
{
InputSetter
:
[{
input
:
this
.
createMergeRequestButton
,
valueAttribute
:
'
data-value
'
,
inputAttribute
:
'
data-action
'
,
},
{
input
:
this
.
createMergeRequestButton
,
valueAttribute
:
'
data-text
'
,
}],
};
}
bindEvents
()
{
this
.
createMergeRequestButton
.
addEventListener
(
'
click
'
,
this
.
onClickCreateMergeRequestButton
.
bind
(
this
));
}
isBusy
()
{
return
this
.
isCreatingMergeRequest
||
this
.
mergeRequestCreated
||
this
.
isCreatingBranch
||
this
.
branchCreated
;
}
onClickCreateMergeRequestButton
(
e
)
{
let
xhr
=
null
;
e
.
preventDefault
();
if
(
this
.
isBusy
())
{
return
;
}
if
(
e
.
target
.
dataset
.
action
===
CREATE_MERGE_REQUEST
)
{
xhr
=
this
.
createMergeRequest
();
}
else
if
(
e
.
target
.
dataset
.
action
===
CREATE_BRANCH
)
{
xhr
=
this
.
createBranch
();
}
xhr
.
fail
(()
=>
{
this
.
isCreatingMergeRequest
=
false
;
this
.
isCreatingBranch
=
false
;
});
xhr
.
always
(()
=>
this
.
enable
());
this
.
disable
();
}
createMergeRequest
()
{
return
$
.
ajax
({
method
:
'
POST
'
,
dataType
:
'
json
'
,
url
:
this
.
createMrPath
,
beforeSend
:
()
=>
(
this
.
isCreatingMergeRequest
=
true
),
})
.
done
((
data
)
=>
{
this
.
mergeRequestCreated
=
true
;
window
.
location
.
href
=
data
.
url
;
})
.
fail
(()
=>
new
Flash
(
'
Failed to create Merge Request. Please try again.
'
));
}
createBranch
()
{
return
$
.
ajax
({
method
:
'
POST
'
,
dataType
:
'
json
'
,
url
:
this
.
createBranchPath
,
beforeSend
:
()
=>
(
this
.
isCreatingBranch
=
true
),
})
.
done
((
data
)
=>
{
this
.
branchCreated
=
true
;
window
.
location
.
href
=
data
.
url
;
})
.
fail
(()
=>
new
Flash
(
'
Failed to create a branch for this issue. Please try again.
'
));
}
}
app/assets/javascripts/issue.js
View file @
df2cba68
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */
/* global Flash */
/* global Flash */
import
CreateMergeRequestDropdown
from
'
./create_merge_request_dropdown
'
;
require
(
'
./flash
'
);
require
(
'
~/lib/utils/text_utility
'
);
...
...
@@ -18,48 +19,49 @@ class Issue {
document
.
querySelector
(
'
#task_status_short
'
).
innerText
=
result
.
task_status_short
;
}
});
Issue
.
initIssueBtnEventListeners
();
this
.
initIssueBtnEventListeners
();
}
Issue
.
$btnNewBranch
=
$
(
'
#new-branch
'
);
Issue
.
createMrDropdownWrap
=
document
.
querySelector
(
'
.create-mr-dropdown-wrap
'
);
Issue
.
initMergeRequests
();
Issue
.
initRelatedBranches
();
Issue
.
initCanCreateBranch
();
if
(
Issue
.
createMrDropdownWrap
)
{
this
.
createMergeRequestDropdown
=
new
CreateMergeRequestDropdown
(
Issue
.
createMrDropdownWrap
);
}
}
static
initIssueBtnEventListeners
()
{
initIssueBtnEventListeners
()
{
const
issueFailMessage
=
'
Unable to update this issue at this time.
'
;
const
closeButtons
=
$
(
'
a.btn-close
'
);
const
isClosedBadge
=
$
(
'
div.status-box-closed
'
);
const
isOpenBadge
=
$
(
'
div.status-box-open
'
);
const
projectIssuesCounter
=
$
(
'
.issue_counter
'
);
const
reopenButtons
=
$
(
'
a.btn-reopen
'
);
return
closeButtons
.
add
(
reopenButtons
).
on
(
'
click
'
,
function
(
e
)
{
var
$
this
,
shouldSubmit
,
url
;
return
closeButtons
.
add
(
reopenButtons
).
on
(
'
click
'
,
(
e
)
=>
{
var
$
button
,
shouldSubmit
,
url
;
e
.
preventDefault
();
e
.
stopImmediatePropagation
();
$
this
=
$
(
this
);
shouldSubmit
=
$
this
.
hasClass
(
'
btn-comment
'
);
$
button
=
$
(
e
.
currentTarget
);
shouldSubmit
=
$
button
.
hasClass
(
'
btn-comment
'
);
if
(
shouldSubmit
)
{
Issue
.
submitNoteForm
(
$
this
.
closest
(
'
form
'
));
Issue
.
submitNoteForm
(
$
button
.
closest
(
'
form
'
));
}
$this
.
prop
(
'
disabled
'
,
true
);
Issue
.
setNewBranchButtonState
(
true
,
null
);
url
=
$this
.
attr
(
'
href
'
);
$button
.
prop
(
'
disabled
'
,
true
);
url
=
$button
.
attr
(
'
href
'
);
return
$
.
ajax
({
type
:
'
PUT
'
,
url
:
url
}).
fail
(
function
(
jqXHR
,
textStatus
,
errorThrown
)
{
new
Flash
(
issueFailMessage
);
Issue
.
initCanCreateBranch
();
}).
done
(
function
(
data
,
textStatus
,
jqXHR
)
{
})
.
fail
(()
=>
new
Flash
(
issueFailMessage
))
.
done
((
data
)
=>
{
if
(
'
id
'
in
data
)
{
$
(
document
).
trigger
(
'
issuable:change
'
);
const
isClosed
=
$
this
.
hasClass
(
'
btn-close
'
);
const
isClosed
=
$
button
.
hasClass
(
'
btn-close
'
);
closeButtons
.
toggleClass
(
'
hidden
'
,
isClosed
);
reopenButtons
.
toggleClass
(
'
hidden
'
,
!
isClosed
);
isClosedBadge
.
toggleClass
(
'
hidden
'
,
!
isClosed
);
...
...
@@ -68,12 +70,21 @@ class Issue {
let
numProjectIssues
=
Number
(
projectIssuesCounter
.
text
().
replace
(
/
[^\d]
/
,
''
));
numProjectIssues
=
isClosed
?
numProjectIssues
-
1
:
numProjectIssues
+
1
;
projectIssuesCounter
.
text
(
gl
.
text
.
addDelimiter
(
numProjectIssues
));
if
(
this
.
createMergeRequestDropdown
)
{
if
(
isClosed
)
{
this
.
createMergeRequestDropdown
.
unavailable
();
this
.
createMergeRequestDropdown
.
disable
();
}
else
{
// We should check in case a branch was created in another tab
this
.
createMergeRequestDropdown
.
checkAbilityToCreateBranch
();
}
}
}
else
{
new
Flash
(
issueFailMessage
);
}
$this
.
prop
(
'
disabled
'
,
false
);
Issue
.
initCanCreateBranch
();
$button
.
prop
(
'
disabled
'
,
false
);
});
});
}
...
...
@@ -109,29 +120,6 @@ class Issue {
}
});
}
static
initCanCreateBranch
()
{
// If the user doesn't have the required permissions the container isn't
// rendered at all.
if
(
Issue
.
$btnNewBranch
.
length
===
0
)
{
return
;
}
return
$
.
getJSON
(
Issue
.
$btnNewBranch
.
data
(
'
path
'
)).
fail
(
function
()
{
Issue
.
setNewBranchButtonState
(
false
,
false
);
new
Flash
(
'
Failed to check if a new branch can be created.
'
);
}).
done
(
function
(
data
)
{
Issue
.
setNewBranchButtonState
(
false
,
data
.
can_create_branch
);
});
}
static
setNewBranchButtonState
(
isPending
,
canCreate
)
{
if
(
Issue
.
$btnNewBranch
.
length
===
0
)
{
return
;
}
Issue
.
$btnNewBranch
.
find
(
'
.available
'
).
toggle
(
!
isPending
&&
canCreate
);
Issue
.
$btnNewBranch
.
find
(
'
.unavailable
'
).
toggle
(
!
isPending
&&
!
canCreate
);
}
}
export
default
Issue
;
app/assets/stylesheets/pages/issues.scss
View file @
df2cba68
...
...
@@ -161,3 +161,86 @@ ul.related-merge-requests > li {
.recaptcha
{
margin-bottom
:
30px
;
}
.new-branch-col
{
padding-top
:
10px
;
}
.create-mr-dropdown-wrap
{
.btn-group
:not
(
.hide
)
{
display
:
flex
;
}
.js-create-merge-request
{
flex-grow
:
1
;
flex-shrink
:
0
;
}
.dropdown-menu
{
width
:
300px
;
opacity
:
1
;
visibility
:
visible
;
transform
:
translateY
(
0
);
display
:
none
;
}
.dropdown-toggle
{
.fa-caret-down
{
pointer-events
:
none
;
margin-left
:
0
;
color
:
inherit
;
margin-left
:
0
;
}
}
li
:not
(
.divider
)
{
padding
:
6px
;
cursor
:
pointer
;
&
:hover
,
&
:focus
{
background-color
:
$dropdown-hover-color
;
color
:
$white-light
;
}
&
.droplab-item-selected
{
.icon-container
{
i
{
visibility
:
visible
;
}
}
}
.icon-container
{
float
:
left
;
padding-left
:
6px
;
i
{
visibility
:
hidden
;
}
}
.description
{
padding-left
:
30px
;
font-size
:
13px
;
strong
{
display
:
block
;
font-weight
:
600
;
}
}
}
}
@media
(
min-width
:
$screen-sm-min
)
{
.new-branch-col
{
padding-top
:
0
;
text-align
:
right
;
}
.create-mr-dropdown-wrap
{
.btn-group
:not
(
.hide
)
{
display
:
inline-block
;
}
}
}
app/controllers/projects/branches_controller.rb
View file @
df2cba68
...
...
@@ -46,20 +46,28 @@ def create
SystemNoteService
.
new_issue_branch
(
issue
,
@project
,
current_user
,
branch_name
)
if
issue
end
if
result
[
:status
]
==
:success
@branch
=
result
[
:branch
]
if
redirect_to_autodeploy
redirect_to
(
url_to_autodeploy_setup
(
project
,
branch_name
),
notice:
view_context
.
autodeploy_flash_notice
(
branch_name
))
else
redirect_to
namespace_project_tree_path
(
@project
.
namespace
,
@project
,
@branch
.
name
)
respond_to
do
|
format
|
format
.
html
do
if
result
[
:status
]
==
:success
if
redirect_to_autodeploy
redirect_to
url_to_autodeploy_setup
(
project
,
branch_name
),
notice:
view_context
.
autodeploy_flash_notice
(
branch_name
)
else
redirect_to
namespace_project_tree_path
(
@project
.
namespace
,
@project
,
branch_name
)
end
else
@error
=
result
[
:message
]
render
action:
'new'
end
end
format
.
json
do
if
result
[
:status
]
==
:success
render
json:
{
name:
branch_name
,
url:
namespace_project_tree_url
(
@project
.
namespace
,
@project
,
branch_name
)
}
else
render
json:
result
[
:messsage
],
status: :unprocessable_entity
end
end
else
@error
=
result
[
:message
]
render
action:
'new'
end
end
...
...
app/controllers/projects/issues_controller.rb
View file @
df2cba68
...
...
@@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action
:redirect_to_external_issue_tracker
,
only:
[
:index
,
:new
]
before_action
:module_enabled
before_action
:issue
,
only:
[
:edit
,
:update
,
:show
,
:referenced_merge_requests
,
:related_branches
,
:can_create_branch
,
:rendered_title
]
:related_branches
,
:can_create_branch
,
:rendered_title
,
:create_merge_request
]
# Allow read any issue
before_action
:authorize_read_issue!
,
only:
[
:show
,
:rendered_title
]
...
...
@@ -22,6 +22,9 @@ class Projects::IssuesController < Projects::ApplicationController
# Allow modify issue
before_action
:authorize_update_issue!
,
only:
[
:edit
,
:update
]
# Allow create a new branch and empty WIP merge request from current issue
before_action
:authorize_create_merge_request!
,
only:
[
:create_merge_request
]
respond_to
:html
def
index
...
...
@@ -191,7 +194,7 @@ def can_create_branch
respond_to
do
|
format
|
format
.
json
do
render
json:
{
can_create_branch:
can_create
}
render
json:
{
can_create_branch:
can_create
,
has_related_branch:
@issue
.
has_related_branch?
}
end
end
end
...
...
@@ -201,6 +204,16 @@ def rendered_title
render
json:
{
title:
view_context
.
markdown_field
(
@issue
,
:title
)
}
end
def
create_merge_request
result
=
MergeRequests
::
CreateFromIssueService
.
new
(
project
,
current_user
,
issue_iid:
issue
.
iid
).
execute
if
result
[
:status
]
==
:success
render
json:
MergeRequestCreateSerializer
.
new
.
represent
(
result
[
:merge_request
])
else
render
json:
result
[
:messsage
],
status: :unprocessable_entity
end
end
protected
def
issue
...
...
@@ -224,6 +237,10 @@ def authorize_admin_issues!
return
render_404
unless
can?
(
current_user
,
:admin_issue
,
@project
)
end
def
authorize_create_merge_request!
return
render_404
unless
can?
(
current_user
,
:push_code
,
@project
)
&&
@issue
.
can_be_worked_on?
(
current_user
)
end
def
module_enabled
return
render_404
unless
@project
.
feature_available?
(
:issues
,
current_user
)
&&
@project
.
default_issues_tracker?
end
...
...
app/models/issue.rb
View file @
df2cba68
...
...
@@ -143,6 +143,14 @@ def related_branches(current_user)
branches_with_iid
-
branches_with_merge_request
end
# Returns boolean if a related branch exists for the current issue
# ignores merge requests branchs
def
has_related_branch?
project
.
repository
.
branch_names
.
any?
do
|
branch
|
/\A
#{
iid
}
-(?!\d+-stable)/i
=~
branch
end
end
# To allow polymorphism with MergeRequest.
def
source_project
project
...
...
app/serializers/merge_request_create_entity.rb
0 → 100644
View file @
df2cba68
class
MergeRequestCreateEntity
<
Grape
::
Entity
expose
:iid
expose
:url
do
|
merge_request
|
Gitlab
::
UrlBuilder
.
build
(
merge_request
)
end
end
app/serializers/merge_request_create_serializer.rb
0 → 100644
View file @
df2cba68
class
MergeRequestCreateSerializer
<
BaseSerializer
entity
MergeRequestCreateEntity
end
app/services/merge_requests/create_from_issue_service.rb
0 → 100644
View file @
df2cba68
module
MergeRequests
class
CreateFromIssueService
<
MergeRequests
::
CreateService
def
execute
return
error
(
'Invalid issue iid'
)
unless
issue_iid
.
present?
&&
issue
.
present?
result
=
CreateBranchService
.
new
(
project
,
current_user
).
execute
(
branch_name
,
ref
)
return
result
if
result
[
:status
]
==
:error
SystemNoteService
.
new_issue_branch
(
issue
,
project
,
current_user
,
branch_name
)
new_merge_request
=
create
(
merge_request
)
if
new_merge_request
.
valid?
success
(
new_merge_request
)
else
error
(
new_merge_request
.
errors
)
end
end
private
def
issue_iid
@isssue_iid
||=
params
.
delete
(
:issue_iid
)
end
def
issue
@issue
||=
IssuesFinder
.
new
(
current_user
,
project_id:
project
.
id
).
find_by
(
iid:
issue_iid
)
end
def
branch_name
@branch_name
||=
issue
.
to_branch_name
end
def
ref
project
.
default_branch
||
'master'
end
def
merge_request
MergeRequests
::
BuildService
.
new
(
project
,
current_user
,
merge_request_params
).
execute
end
def
merge_request_params
{
source_project_id:
project
.
id
,
source_branch:
branch_name
,
target_project_id:
project
.
id
}
end
def
success
(
merge_request
)
super
().
merge
(
merge_request:
merge_request
)
end
end
end
app/views/projects/issues/_new_branch.html.haml
View file @
df2cba68
-
if
can?
(
current_user
,
:push_code
,
@project
)
.pull-right
#new-branch
.new-branch
{
'data-path'
=>
can_create_branch_namespace_project_issue_path
(
@project
.
namespace
,
@project
,
@issue
)
}