Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
Boxiang Sun
gitlab-ce
Commits
c5389054
Commit
c5389054
authored
May 25, 2018
by
André Luís
Committed by
Phil Hughes
May 25, 2018
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Resolve "Show CI pipeline status in Web IDE"
parent
de12348e
Changes
14
Show whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
386 additions
and
48 deletions
+386
-48
app/assets/javascripts/api.js
app/assets/javascripts/api.js
+14
-0
app/assets/javascripts/ide/components/ide.vue
app/assets/javascripts/ide/components/ide.vue
+1
-3
app/assets/javascripts/ide/components/ide_status_bar.vue
app/assets/javascripts/ide/components/ide_status_bar.vue
+36
-1
app/assets/javascripts/ide/services/index.js
app/assets/javascripts/ide/services/index.js
+4
-0
app/assets/javascripts/ide/stores/actions/project.js
app/assets/javascripts/ide/stores/actions/project.js
+75
-21
app/assets/javascripts/ide/stores/mutation_types.js
app/assets/javascripts/ide/stores/mutation_types.js
+1
-0
app/assets/javascripts/ide/stores/mutations/branch.js
app/assets/javascripts/ide/stores/mutations/branch.js
+9
-0
app/assets/stylesheets/framework/variables.scss
app/assets/stylesheets/framework/variables.scss
+1
-1
app/assets/stylesheets/pages/repo.scss
app/assets/stylesheets/pages/repo.scss
+9
-3
changelogs/unreleased/44579-ide-add-pipeline-to-status-bar.yml
...elogs/unreleased/44579-ide-add-pipeline-to-status-bar.yml
+5
-0
spec/javascripts/api_spec.js
spec/javascripts/api_spec.js
+21
-0
spec/javascripts/ide/mock_data.js
spec/javascripts/ide/mock_data.js
+34
-0
spec/javascripts/ide/stores/actions/project_spec.js
spec/javascripts/ide/stores/actions/project_spec.js
+140
-19
spec/javascripts/ide/stores/mutations/branch_spec.js
spec/javascripts/ide/stores/mutations/branch_spec.js
+36
-0
No files found.
app/assets/javascripts/api.js
View file @
c5389054
...
...
@@ -21,6 +21,7 @@ const Api = {
issuableTemplatePath
:
'
/:namespace_path/:project_path/templates/:type/:key
'
,
usersPath
:
'
/api/:version/users.json
'
,
commitPath
:
'
/api/:version/projects/:id/repository/commits
'
,
commitPipelinesPath
:
'
/:project_id/commit/:sha/pipelines
'
,
branchSinglePath
:
'
/api/:version/projects/:id/repository/branches/:branch
'
,
createBranchPath
:
'
/api/:version/projects/:id/repository/branches
'
,
pipelinesPath
:
'
/api/:version/projects/:id/pipelines
'
,
...
...
@@ -166,6 +167,19 @@ const Api = {
});
},
commitPipelines
(
projectId
,
sha
)
{
const
encodedProjectId
=
projectId
.
split
(
'
/
'
)
.
map
(
fragment
=>
encodeURIComponent
(
fragment
))
.
join
(
'
/
'
);
const
url
=
Api
.
buildUrl
(
Api
.
commitPipelinesPath
)
.
replace
(
'
:project_id
'
,
encodedProjectId
)
.
replace
(
'
:sha
'
,
encodeURIComponent
(
sha
));
return
axios
.
get
(
url
);
},
branchSingle
(
id
,
branch
)
{
const
url
=
Api
.
buildUrl
(
Api
.
branchSinglePath
)
.
replace
(
'
:id
'
,
encodeURIComponent
(
id
))
...
...
app/assets/javascripts/ide/components/ide.vue
View file @
c5389054
...
...
@@ -123,8 +123,6 @@ export default {
</
template
>
</div>
</div>
<ide-status-bar
:file=
"activeFile"
/>
<ide-status-bar
:file=
"activeFile"
/>
</article>
</template>
app/assets/javascripts/ide/components/ide_status_bar.vue
View file @
c5389054
<
script
>
import
{
mapGetters
}
from
'
vuex
'
;
import
{
map
Actions
,
mapState
,
map
Getters
}
from
'
vuex
'
;
import
icon
from
'
~/vue_shared/components/icon.vue
'
;
import
tooltip
from
'
~/vue_shared/directives/tooltip
'
;
import
timeAgoMixin
from
'
~/vue_shared/mixins/timeago
'
;
import
CiIcon
from
'
../../vue_shared/components/ci_icon.vue
'
;
import
userAvatarImage
from
'
../../vue_shared/components/user_avatar/user_avatar_image.vue
'
;
export
default
{
components
:
{
icon
,
userAvatarImage
,
CiIcon
,
},
directives
:
{
tooltip
,
...
...
@@ -27,8 +29,16 @@ export default {
};
},
computed
:
{
...
mapState
([
'
currentBranchId
'
,
'
currentProjectId
'
]),
...
mapGetters
([
'
currentProject
'
,
'
lastCommit
'
]),
},
watch
:
{
lastCommit
()
{
if
(
!
this
.
isPollingInitialized
)
{
this
.
initPipelinePolling
();
}
},
},
mounted
()
{
this
.
startTimer
();
},
...
...
@@ -36,13 +46,21 @@ export default {
if
(
this
.
intervalId
)
{
clearInterval
(
this
.
intervalId
);
}
if
(
this
.
isPollingInitialized
)
{
this
.
stopPipelinePolling
();
}
},
methods
:
{
...
mapActions
([
'
pipelinePoll
'
,
'
stopPipelinePolling
'
]),
startTimer
()
{
this
.
intervalId
=
setInterval
(()
=>
{
this
.
commitAgeUpdate
();
},
1000
);
},
initPipelinePolling
()
{
this
.
pipelinePoll
();
this
.
isPollingInitialized
=
true
;
},
commitAgeUpdate
()
{
if
(
this
.
lastCommit
)
{
this
.
lastCommitFormatedAge
=
this
.
timeFormated
(
this
.
lastCommit
.
committed_date
);
...
...
@@ -61,6 +79,23 @@ export default {
class=
"ide-status-branch"
v-if=
"lastCommit && lastCommitFormatedAge"
>
<span
class=
"ide-status-pipeline"
v-if=
"lastCommit.pipeline && lastCommit.pipeline.details"
>
<ci-icon
:status=
"lastCommit.pipeline.details.status"
v-tooltip
:title=
"lastCommit.pipeline.details.status.text"
/>
Pipeline
<a
class=
"monospace"
:href=
"lastCommit.pipeline.details.status.details_path"
>
#
{{
lastCommit
.
pipeline
.
id
}}
</a>
{{
lastCommit
.
pipeline
.
details
.
status
.
text
}}
for
</span>
<icon
name=
"commit"
/>
...
...
app/assets/javascripts/ide/services/index.js
View file @
c5389054
...
...
@@ -75,4 +75,8 @@ export default {
},
});
},
lastCommitPipelines
({
getters
})
{
const
commitSha
=
getters
.
lastCommit
.
id
;
return
Api
.
commitPipelines
(
getters
.
currentProject
.
path_with_namespace
,
commitSha
);
},
};
app/assets/javascripts/ide/stores/actions/project.js
View file @
c5389054
import
Visibility
from
'
visibilityjs
'
;
import
flash
from
'
~/flash
'
;
import
{
__
}
from
'
~/locale
'
;
import
service
from
'
../../services
'
;
import
*
as
types
from
'
../mutation_types
'
;
import
Poll
from
'
../../../lib/utils/poll
'
;
let
eTagPoll
;
export
const
getProjectData
=
(
{
commit
,
state
,
dispatch
},
...
...
@@ -21,7 +26,7 @@ export const getProjectData = (
})
.
catch
(()
=>
{
flash
(
'
Error loading project data. Please try again.
'
,
__
(
'
Error loading project data. Please try again.
'
)
,
'
alert
'
,
document
,
null
,
...
...
@@ -59,7 +64,7 @@ export const getBranchData = (
})
.
catch
(()
=>
{
flash
(
'
Error loading branch data. Please try again.
'
,
__
(
'
Error loading branch data. Please try again.
'
)
,
'
alert
'
,
document
,
null
,
...
...
@@ -73,10 +78,8 @@ export const getBranchData = (
}
});
export
const
refreshLastCommitData
=
(
{
commit
,
state
,
dispatch
},
{
projectId
,
branchId
}
=
{},
)
=>
service
export
const
refreshLastCommitData
=
({
commit
,
state
,
dispatch
},
{
projectId
,
branchId
}
=
{})
=>
service
.
getBranchData
(
projectId
,
branchId
)
.
then
(({
data
})
=>
{
commit
(
types
.
SET_BRANCH_COMMIT
,
{
...
...
@@ -86,12 +89,63 @@ export const refreshLastCommitData = (
});
})
.
catch
(()
=>
{
flash
(
__
(
'
Error loading last commit.
'
),
'
alert
'
,
document
,
null
,
false
,
true
);
});
export
const
pollSuccessCallBack
=
({
commit
,
state
,
dispatch
},
{
data
})
=>
{
if
(
data
.
pipelines
&&
data
.
pipelines
.
length
)
{
const
lastCommitHash
=
state
.
projects
[
state
.
currentProjectId
].
branches
[
state
.
currentBranchId
].
commit
.
id
;
const
lastCommitPipeline
=
data
.
pipelines
.
find
(
pipeline
=>
pipeline
.
commit
.
id
===
lastCommitHash
,
);
commit
(
types
.
SET_LAST_COMMIT_PIPELINE
,
{
projectId
:
state
.
currentProjectId
,
branchId
:
state
.
currentBranchId
,
pipeline
:
lastCommitPipeline
||
{},
});
}
return
data
;
};
export
const
pipelinePoll
=
({
getters
,
dispatch
})
=>
{
eTagPoll
=
new
Poll
({
resource
:
service
,
method
:
'
lastCommitPipelines
'
,
data
:
{
getters
,
},
successCallback
:
({
data
})
=>
dispatch
(
'
pollSuccessCallBack
'
,
{
data
}),
errorCallback
:
()
=>
{
flash
(
'
Error loading last commit.
'
,
__
(
'
Something went wrong while fetching the latest pipeline status.
'
)
,
'
alert
'
,
document
,
null
,
false
,
true
,
);
},
});
if
(
!
Visibility
.
hidden
())
{
eTagPoll
.
makeRequest
();
}
Visibility
.
change
(()
=>
{
if
(
!
Visibility
.
hidden
())
{
eTagPoll
.
restart
();
}
else
{
eTagPoll
.
stop
();
}
});
};
export
const
stopPipelinePolling
=
()
=>
{
eTagPoll
.
stop
();
};
export
const
restartPipelinePolling
=
()
=>
{
eTagPoll
.
restart
();
};
app/assets/javascripts/ide/stores/mutation_types.js
View file @
c5389054
...
...
@@ -23,6 +23,7 @@ export const SET_BRANCH = 'SET_BRANCH';
export
const
SET_BRANCH_COMMIT
=
'
SET_BRANCH_COMMIT
'
;
export
const
SET_BRANCH_WORKING_REFERENCE
=
'
SET_BRANCH_WORKING_REFERENCE
'
;
export
const
TOGGLE_BRANCH_OPEN
=
'
TOGGLE_BRANCH_OPEN
'
;
export
const
SET_LAST_COMMIT_PIPELINE
=
'
SET_LAST_COMMIT_PIPELINE
'
;
// Tree mutation types
export
const
SET_DIRECTORY_DATA
=
'
SET_DIRECTORY_DATA
'
;
...
...
app/assets/javascripts/ide/stores/mutations/branch.js
View file @
c5389054
...
...
@@ -14,6 +14,10 @@ export default {
treeId
:
`
${
projectPath
}
/
${
branchName
}
`
,
active
:
true
,
workingReference
:
''
,
commit
:
{
...
branch
.
commit
,
pipeline
:
{},
},
},
},
});
...
...
@@ -28,4 +32,9 @@ export default {
commit
,
});
},
[
types
.
SET_LAST_COMMIT_PIPELINE
](
state
,
{
projectId
,
branchId
,
pipeline
})
{
Object
.
assign
(
state
.
projects
[
projectId
].
branches
[
branchId
].
commit
,
{
pipeline
,
});
},
};
app/assets/stylesheets/framework/variables.scss
View file @
c5389054
...
...
@@ -230,7 +230,7 @@ $row-hover: $blue-50;
$row-hover-border
:
$blue-200
;
$progress-color
:
#c0392b
;
$header-height
:
40px
;
$ide-statusbar-height
:
2
7
px
;
$ide-statusbar-height
:
2
5
px
;
$fixed-layout-width
:
1280px
;
$limited-layout-width
:
990px
;
$limited-layout-width-sm
:
790px
;
...
...
app/assets/stylesheets/pages/repo.scss
View file @
c5389054
...
...
@@ -22,7 +22,6 @@
height
:
calc
(
100vh
-
#{
$header-height
}
);
margin-top
:
0
;
border-top
:
1px
solid
$white-dark
;
border-bottom
:
1px
solid
$white-dark
;
padding-bottom
:
$ide-statusbar-height
;
&
.is-collapsed
{
...
...
@@ -380,7 +379,7 @@
.ide-status-bar
{
border-top
:
1px
solid
$white-dark
;
padding
:
$gl-bar-padding
$gl-padding
;
padding
:
2px
$gl-padding-8
0
;
background
:
$white-light
;
display
:
flex
;
justify-content
:
space-between
;
...
...
@@ -391,12 +390,19 @@
left
:
0
;
width
:
100%
;
font-size
:
12px
;
line-height
:
22px
;
*
{
font-size
:
inherit
;
}
>
div
+
div
{
padding-left
:
$gl-padding
;
}
svg
{
vertical-align
:
middle
;
vertical-align
:
sub
;
}
}
...
...
changelogs/unreleased/44579-ide-add-pipeline-to-status-bar.yml
0 → 100644
View file @
c5389054
---
title
:
Add pipeline status to the status bar of the Web IDE
merge_request
:
author
:
type
:
added
spec/javascripts/api_spec.js
View file @
c5389054
...
...
@@ -341,4 +341,25 @@ describe('Api', () => {
.
catch
(
done
.
fail
);
});
});
describe
(
'
commitPipelines
'
,
()
=>
{
it
(
'
fetches pipelines for a given commit
'
,
done
=>
{
const
projectId
=
'
example/foobar
'
;
const
commitSha
=
'
abc123def
'
;
const
expectedUrl
=
`
${
dummyUrlRoot
}
/
${
projectId
}
/commit/
${
commitSha
}
/pipelines`
;
mock
.
onGet
(
expectedUrl
).
reply
(
200
,
[
{
name
:
'
test
'
,
},
]);
Api
.
commitPipelines
(
projectId
,
commitSha
)
.
then
(({
data
})
=>
{
expect
(
data
.
length
).
toBe
(
1
);
expect
(
data
[
0
].
name
).
toBe
(
'
test
'
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
});
});
spec/javascripts/ide/mock_data.js
View file @
c5389054
...
...
@@ -59,3 +59,37 @@ export const jobs = [
duration
:
1
,
},
];
export
const
fullPipelinesResponse
=
{
data
:
{
count
:
{
all
:
2
,
},
pipelines
:
[
{
id
:
'
51
'
,
commit
:
{
id
:
'
xxxxxxxxxxxxxxxxxxxx
'
,
},
details
:
{
status
:
{
icon
:
'
status_failed
'
,
text
:
'
failed
'
,
},
},
},
{
id
:
'
50
'
,
commit
:
{
id
:
'
abc123def456ghi789jkl
'
,
},
details
:
{
status
:
{
icon
:
'
status_passed
'
,
text
:
'
passed
'
,
},
},
},
],
},
};
spec/javascripts/ide/stores/actions/project_spec.js
View file @
c5389054
import
{
refreshLastCommitData
,
}
from
'
~/ide/stores/actions
'
;
import
Visibility
from
'
visibilityjs
'
;
import
MockAdapter
from
'
axios-mock-adapter
'
;
import
{
refreshLastCommitData
,
pollSuccessCallBack
}
from
'
~/ide/stores/actions
'
;
import
store
from
'
~/ide/stores
'
;
import
service
from
'
~/ide/services
'
;
import
axios
from
'
~/lib/utils/axios_utils
'
;
import
{
fullPipelinesResponse
}
from
'
../../mock_data
'
;
import
{
resetStore
}
from
'
../../helpers
'
;
import
testAction
from
'
../../../helpers/vuex_action_helper
'
;
describe
(
'
IDE store project actions
'
,
()
=>
{
const
setProjectState
=
()
=>
{
store
.
state
.
currentProjectId
=
'
abc/def
'
;
store
.
state
.
currentBranchId
=
'
master
'
;
store
.
state
.
projects
[
'
abc/def
'
]
=
{
id
:
4
,
path_with_namespace
:
'
abc/def
'
,
branches
:
{
master
:
{
commit
:
{
id
:
'
abc123def456ghi789jkl
'
,
title
:
'
example
'
,
},
},
},
};
};
beforeEach
(()
=>
{
store
.
state
.
projects
.
abcproject
=
{};
store
.
state
.
projects
[
'
abc/def
'
]
=
{};
});
afterEach
(()
=>
{
...
...
@@ -17,18 +36,16 @@ describe('IDE store project actions', () => {
describe
(
'
refreshLastCommitData
'
,
()
=>
{
beforeEach
(()
=>
{
store
.
state
.
currentProjectId
=
'
abc
project
'
;
store
.
state
.
currentProjectId
=
'
abc
/def
'
;
store
.
state
.
currentBranchId
=
'
master
'
;
store
.
state
.
projects
.
abcproject
=
{
store
.
state
.
projects
[
'
abc/def
'
]
=
{
id
:
4
,
branches
:
{
master
:
{
commit
:
null
,
},
},
};
});
it
(
'
calls the service
'
,
done
=>
{
spyOn
(
service
,
'
getBranchData
'
).
and
.
returnValue
(
Promise
.
resolve
({
data
:
{
...
...
@@ -36,14 +53,16 @@ describe('IDE store project actions', () => {
},
}),
);
});
it
(
'
calls the service
'
,
done
=>
{
store
.
dispatch
(
'
refreshLastCommitData
'
,
{
projectId
:
store
.
state
.
currentProjectId
,
branchId
:
store
.
state
.
currentBranchId
,
})
.
then
(()
=>
{
expect
(
service
.
getBranchData
).
toHaveBeenCalledWith
(
'
abc
project
'
,
'
master
'
);
expect
(
service
.
getBranchData
).
toHaveBeenCalledWith
(
'
abc
/def
'
,
'
master
'
);
done
();
})
...
...
@@ -53,16 +72,118 @@ describe('IDE store project actions', () => {
it
(
'
commits getBranchData
'
,
done
=>
{
testAction
(
refreshLastCommitData
,
{},
{},
[{
{
projectId
:
store
.
state
.
currentProjectId
,
branchId
:
store
.
state
.
currentBranchId
,
},
store
.
state
,
[
{
type
:
'
SET_BRANCH_COMMIT
'
,
payload
:
{
projectId
:
'
abcproject
'
,
projectId
:
'
abc/def
'
,
branchId
:
'
master
'
,
commit
:
{
id
:
'
123
'
},
},
}],
// mutations
},
],
// mutations
[
{
type
:
'
getLastCommitPipeline
'
,
payload
:
{
projectId
:
'
abc/def
'
,
projectIdNumber
:
store
.
state
.
projects
[
'
abc/def
'
].
id
,
branchId
:
'
master
'
,
},
},
],
// action
done
,
);
});
});
describe
(
'
pipelinePoll
'
,
()
=>
{
let
mock
;
beforeEach
(()
=>
{
setProjectState
();
jasmine
.
clock
().
install
();
mock
=
new
MockAdapter
(
axios
);
mock
.
onGet
(
'
/abc/def/commit/abc123def456ghi789jkl/pipelines
'
)
.
reply
(
200
,
{
data
:
{
foo
:
'
bar
'
}
},
{
'
poll-interval
'
:
'
10000
'
});
});
afterEach
(()
=>
{
jasmine
.
clock
().
uninstall
();
mock
.
restore
();
store
.
dispatch
(
'
stopPipelinePolling
'
);
});
it
(
'
calls service periodically
'
,
done
=>
{
spyOn
(
axios
,
'
get
'
).
and
.
callThrough
();
spyOn
(
Visibility
,
'
hidden
'
).
and
.
returnValue
(
false
);
store
.
dispatch
(
'
pipelinePoll
'
)
.
then
(()
=>
{
jasmine
.
clock
().
tick
(
1000
);
expect
(
axios
.
get
).
toHaveBeenCalled
();
expect
(
axios
.
get
.
calls
.
count
()).
toBe
(
1
);
})
.
then
(()
=>
new
Promise
(
resolve
=>
requestAnimationFrame
(
resolve
)))
.
then
(()
=>
{
jasmine
.
clock
().
tick
(
10000
);
expect
(
axios
.
get
.
calls
.
count
()).
toBe
(
2
);
})
.
then
(()
=>
new
Promise
(
resolve
=>
requestAnimationFrame
(
resolve
)))
.
then
(()
=>
{
jasmine
.
clock
().
tick
(
10000
);
expect
(
axios
.
get
.
calls
.
count
()).
toBe
(
3
);
})
.
then
(()
=>
new
Promise
(
resolve
=>
requestAnimationFrame
(
resolve
)))
.
then
(()
=>
{
jasmine
.
clock
().
tick
(
10000
);
expect
(
axios
.
get
.
calls
.
count
()).
toBe
(
4
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
});
describe
(
'
pollSuccessCallBack
'
,
()
=>
{
beforeEach
(()
=>
{
setProjectState
();
});
it
(
'
commits correct pipeline
'
,
done
=>
{
testAction
(
pollSuccessCallBack
,
fullPipelinesResponse
,
store
.
state
,
[
{
type
:
'
SET_LAST_COMMIT_PIPELINE
'
,
payload
:
{
projectId
:
'
abc/def
'
,
branchId
:
'
master
'
,
pipeline
:
{
id
:
'
50
'
,
commit
:
{
id
:
'
abc123def456ghi789jkl
'
,
},
details
:
{
status
:
{
icon
:
'
status_passed
'
,
text
:
'
passed
'
,
},
},
},
},
},
],
// mutations
[],
// action
done
,
);
...
...
spec/javascripts/ide/stores/mutations/branch_spec.js
View file @
c5389054
...
...
@@ -37,4 +37,40 @@ describe('Multi-file store branch mutations', () => {
expect
(
localState
.
projects
.
Example
.
branches
.
master
.
commit
.
title
).
toBe
(
'
Example commit
'
);
});
});
describe
(
'
SET_LAST_COMMIT_PIPELINE
'
,
()
=>
{
it
(
'
sets the pipeline for the last commit on current project
'
,
()
=>
{
localState
.
projects
=
{
Example
:
{
branches
:
{
master
:
{
commit
:
{},
},
},
},
};
mutations
.
SET_LAST_COMMIT_PIPELINE
(
localState
,
{
projectId
:
'
Example
'
,
branchId
:
'
master
'
,
pipeline
:
{
id
:
'
50
'
,
details
:
{
status
:
{
icon
:
'
status_passed
'
,
text
:
'
passed
'
,
},
},
},
});
expect
(
localState
.
projects
.
Example
.
branches
.
master
.
commit
.
pipeline
.
id
).
toBe
(
'
50
'
);
expect
(
localState
.
projects
.
Example
.
branches
.
master
.
commit
.
pipeline
.
details
.
status
.
text
).
toBe
(
'
passed
'
,
);
expect
(
localState
.
projects
.
Example
.
branches
.
master
.
commit
.
pipeline
.
details
.
status
.
icon
).
toBe
(
'
status_passed
'
,
);
});
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment