Commit 12fbce2e authored by Luke "Jared" Bennett's avatar Luke "Jared" Bennett

Merge branch 'master' into 'add-sentry-js-again-with-vue'

# Conflicts:
#   db/schema.rb
parents 1bf694fc 79dc8171
...@@ -412,18 +412,6 @@ rake karma: ...@@ -412,18 +412,6 @@ rake karma:
paths: paths:
- coverage-javascript/ - coverage-javascript/
bundler:audit:
stage: test
<<: *ruby-static-analysis
<<: *dedicated-runner
only:
- master@gitlab-org/gitlab-ce
- master@gitlab-org/gitlab-ee
- master@gitlab/gitlabhq
- master@gitlab/gitlab-ee
script:
- "bundle exec bundle-audit check --update --ignore CVE-2016-4658"
.migration-paths: &migration-paths .migration-paths: &migration-paths
stage: test stage: test
<<: *dedicated-runner <<: *dedicated-runner
......
...@@ -13,27 +13,29 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._ ...@@ -13,27 +13,29 @@ _This notice should stay as the first item in the CONTRIBUTING.MD file._
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
- [Contributor license agreement](#contributor-license-agreement)
- [Contribute to GitLab](#contribute-to-gitlab) - [Contribute to GitLab](#contribute-to-gitlab)
- [Security vulnerability disclosure](#security-vulnerability-disclosure) - [Security vulnerability disclosure](#security-vulnerability-disclosure)
- [Closing policy for issues and merge requests](#closing-policy-for-issues-and-merge-requests) - [Closing policy for issues and merge requests](#closing-policy-for-issues-and-merge-requests)
- [Helping others](#helping-others) - [Helping others](#helping-others)
- [I want to contribute!](#i-want-to-contribute) - [I want to contribute!](#i-want-to-contribute)
- [Implement design & UI elements](#implement-design-ui-elements) - [Workflow labels](#workflow-labels)
- [Release retrospective and kickoff](#release-retrospective-and-kickoff) - [Type labels (~"feature proposal", ~bug, ~customer, etc.)](#type-labels-feature-proposal-bug-customer-etc)
- [Retrospective](#retrospective) - [Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)](#subject-labels-wiki-container-registry-ldap-api-etc)
- [Kickoff](#kickoff) - [Team labels (~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc.)](#team-labels-ci-discussion-edge-frontend-platform-etc)
- [Priority labels (~Deliverable and ~Stretch)](#priority-labels-deliverable-and-stretch)
- [Label for community contributors (~"Accepting Merge Requests")](#label-for-community-contributors-accepting-merge-requests)
- [Implement design & UI elements](#implement-design--ui-elements)
- [Issue tracker](#issue-tracker) - [Issue tracker](#issue-tracker)
- [Feature proposals](#feature-proposals) - [Issue triaging](#issue-triaging)
- [Issue tracker guidelines](#issue-tracker-guidelines) - [Feature proposals](#feature-proposals)
- [Issue weight](#issue-weight) - [Issue tracker guidelines](#issue-tracker-guidelines)
- [Regression issues](#regression-issues) - [Issue weight](#issue-weight)
- [Technical debt](#technical-debt) - [Regression issues](#regression-issues)
- [Stewardship](#stewardship) - [Technical debt](#technical-debt)
- [Stewardship](#stewardship)
- [Merge requests](#merge-requests) - [Merge requests](#merge-requests)
- [Merge request guidelines](#merge-request-guidelines) - [Merge request guidelines](#merge-request-guidelines)
- [Contribution acceptance criteria](#contribution-acceptance-criteria) - [Contribution acceptance criteria](#contribution-acceptance-criteria)
- [Changes for Stable Releases](#changes-for-stable-releases)
- [Definition of done](#definition-of-done) - [Definition of done](#definition-of-done)
- [Style guides](#style-guides) - [Style guides](#style-guides)
- [Code of conduct](#code-of-conduct) - [Code of conduct](#code-of-conduct)
...@@ -103,34 +105,125 @@ contributing to GitLab. ...@@ -103,34 +105,125 @@ contributing to GitLab.
## Workflow labels ## Workflow labels
Labelling issues is described in the [GitLab Inc engineering workflow]. To allow for asynchronous issue handling, we use [milestones][milestones-page]
and [labels][labels-page]. Leads and product managers handle most of the
scheduling into milestones. Labelling is a task for everyone.
## Implement design & UI elements Most issues will have labels for at least one of the following:
Please see the [UX Guide for GitLab]. - Type: ~"feature proposal", ~bug, ~customer, etc.
- Subject: ~wiki, ~"container registry", ~ldap, ~api, etc.
- Team: ~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc.
- Priority: ~Deliverable, ~Stretch
All labels, their meaning and priority are defined on the
[labels page][labels-page].
If you come across an issue that has none of these, and you're allowed to set
labels, you can _always_ add the team and type, and often also the subject.
[milestones-page]: https://gitlab.com/gitlab-org/gitlab-ce/milestones
[labels-page]: https://gitlab.com/gitlab-org/gitlab-ce/labels
### Type labels (~"feature proposal", ~bug, ~customer, etc.)
Type labels are very important. They define what kind of issue this is. Every
issue should have one or more.
Examples of type labels are ~"feature proposal", ~bug, ~customer, ~security,
and ~"direction".
A number of type labels have a priority assigned to them, which automatically
makes them float to the top, depending on their importance.
Type labels are always lowercase, and can have any color, besides blue (which is
already reserved for subject labels).
The descriptions on the [labels page][labels-page] explain what falls under each type label.
### Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)
Subject labels are labels that define what area or feature of GitLab this issue
hits. They are not always necessary, but very convenient.
If you are an expert in a particular area, it makes it easier to find issues to
work on. You can also subscribe to those labels to receive an email each time an
issue is labelled with a subject label corresponding to your expertise.
Examples of subject labels are ~wiki, ~"container registry", ~ldap, ~api,
~issues, ~"merge requests", ~labels, and ~"container registry".
Subject labels are always all-lowercase.
### Team labels (~CI, ~Discussion, ~Edge, ~Frontend, ~Platform, etc.)
Team labels specify what team is responsible for this issue.
Assigning a team label makes sure issues get the attention of the appropriate
people.
The current team labels are ~Build, ~CI, ~Discussion, ~Documentation, ~Edge,
~Frontend, ~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX".
The descriptions on the [labels page][labels-page] explain what falls under the
responsibility of each team.
Team labels are always capitalized so that they show up as the first label for
any issue.
### Priority labels (~Deliverable and ~Stretch)
## Release retrospective and kickoff Priority labels help us clearly communicate expectations of the work for the
release. There are two levels of priority labels:
### Retrospective - ~Deliverable: Issues that are expected to be delivered in the current
milestone.
- ~Stretch: Issues that are a stretch goal for delivering in the current
milestone. If these issues are not done in the current release, they will
strongly be considered for the next release.
After each release, we have a retrospective call where we discuss what went well, ### Label for community contributors (~"Accepting Merge Requests")
what went wrong, and what we can improve for the next release. The
[retrospective notes] are public and you are invited to comment on them.
If you're interested, you can even join the
[retrospective call][retro-kickoff-call], on the first working day after the
22nd at 6pm CET / 9am PST.
### Kickoff Issues that are beneficial to our users, 'nice to haves', that we currently do
not have the capacity for or want to give the priority to, are labeled as
~"Accepting Merge Requests", so the community can make a contribution.
Before working on the next release, we have a Community contributors can submit merge requests for any issue they want, but
kickoff call to explain what we expect to ship in the next release. The the ~"Accepting Merge Requests" label has a special meaning. It points to
[kickoff notes] are public and you are invited to comment on them. changes that:
If you're interested, you can even join the [kickoff call][retro-kickoff-call],
on the first working day after the 7th at 6pm CET / 9am PST..
[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing 1. We already agreed on,
[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing 1. Are well-defined,
[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206 1. Are likely to get accepted by a maintainer.
We want to avoid a situation when a contributor picks an
~"Accepting Merge Requests" issue and then their merge request gets closed,
because we realize that it does not fit our vision, or we want to solve it in a
different way.
We add the ~"Accepting Merge Requests" label to:
- Low priority ~bug issues (i.e. we do not add it to the bugs that we want to
solve in the ~"Next Patch Release")
- Small ~"feature proposal" that do not need ~UX / ~"Product work", or for which
the ~UX / ~"Product work" is already done
- Small ~"technical debt" issues
After adding the ~"Accepting Merge Requests" label, we try to estimate the
[weight](#issue-weight) of the issue. We use issue weight to let contributors
know how difficult the issue is. Additionally:
- We advertise [~"Accepting Merge Requests" issues with weight < 5][up-for-grabs]
as suitable for people that have never contributed to GitLab before on the
[Up For Grabs campaign](http://up-for-grabs.net)
- We encourage people that have never contributed to any open source project to
look for [~"Accepting Merge Requests" issues with a weight of 1][firt-timers]
[up-for-grabs]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name=Accepting+Merge+Requests&scope=all&sort=weight_asc&state=opened
[firt-timers]: https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=Accepting+Merge+Requests&scope=all&sort=upvotes_desc&state=opened&weight=1
## Implement design & UI elements
Please see the [UX Guide for GitLab].
## Issue tracker ## Issue tracker
...@@ -154,6 +247,21 @@ If it happens that you know the solution to an existing bug, please first ...@@ -154,6 +247,21 @@ If it happens that you know the solution to an existing bug, please first
open the issue in order to keep track of it and then open the relevant merge open the issue in order to keep track of it and then open the relevant merge
request that potentially fixes it. request that potentially fixes it.
### Issue triaging
Our issue triage policies are [described in our handbook]. You are very welcome
to help the GitLab team triage issues. We also organize [issue bash events] once
every quarter.
The most important thing is making sure valid issues receive feedback from the
development team. Therefore the priority is mentioning developers that can help
on those issues. Please select someone with relevant experience from the
[GitLab team][team]. If there is nobody mentioned with that expertise look in
the commit history for the affected files to find someone.
[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/
[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815
### Feature proposals ### Feature proposals
To create a feature proposal for CE, open an issue on the To create a feature proposal for CE, open an issue on the
...@@ -327,13 +435,17 @@ request is as follows: ...@@ -327,13 +435,17 @@ request is as follows:
"Description" field. "Description" field.
1. If you are contributing documentation, choose `Documentation` from the 1. If you are contributing documentation, choose `Documentation` from the
"Choose a template" menu and fill in the template. "Choose a template" menu and fill in the template.
1. Mention the issue(s) your merge request solves, using the `Solves #XXX` or
`Closes #XXX` syntax to auto-close the issue(s) once the merge request will
be merged.
1. If you're allowed to, set a relevant milestone and labels
1. If the MR changes the UI it should include *Before* and *After* screenshots 1. If the MR changes the UI it should include *Before* and *After* screenshots
1. If the MR changes CSS classes please include the list of affected pages, 1. If the MR changes CSS classes please include the list of affected pages,
`grep css-class ./app -R` `grep css-class ./app -R`
1. Link any relevant [issues][ce-tracker] in the merge request description and
leave a comment on them with a link back to the MR
1. Be prepared to answer questions and incorporate feedback even if requests 1. Be prepared to answer questions and incorporate feedback even if requests
for this arrive weeks or months after your MR submission for this arrive weeks or months after your MR submission
1. If a discussion has been addressed, select the "Resolve discussion" button
beneath it to mark it resolved.
1. If your MR touches code that executes shell commands, reads or opens files or 1. If your MR touches code that executes shell commands, reads or opens files or
handles paths to files on disk, make sure it adheres to the handles paths to files on disk, make sure it adheres to the
[shell command guidelines](doc/development/shell_commands.md) [shell command guidelines](doc/development/shell_commands.md)
...@@ -369,24 +481,6 @@ Please ensure that your merge request meets the contribution acceptance criteria ...@@ -369,24 +481,6 @@ Please ensure that your merge request meets the contribution acceptance criteria
When having your code reviewed and when reviewing merge requests please take the When having your code reviewed and when reviewing merge requests please take the
[code review guidelines](doc/development/code_review.md) into account. [code review guidelines](doc/development/code_review.md) into account.
### Getting your merge request reviewed, approved, and merged
There are a few rules to get your merge request accepted:
1. Your merge request should only be **merged by a [maintainer][team]**.
1. If your merge request includes only backend changes [^1], it must be
**approved by a [backend maintainer][team]**.
1. If your merge request includes only frontend changes [^1], it must be
**approved by a [frontend maintainer][team]**.
1. If your merge request includes frontend and backend changes [^1], it must
be **approved by a [frontend and a backend maintainer][team]**.
1. To lower the amount of merge requests maintainers need to review, you can
ask or assign any [reviewers][team] for a first review.
1. If you need some guidance (e.g. it's your first merge request), feel free
to ask one of the [Merge request coaches][team].
1. The reviewer will assign the merge request to a maintainer once the
reviewer is satisfied with the state of the merge request.
### Contribution acceptance criteria ### Contribution acceptance criteria
1. The change is as small as possible 1. The change is as small as possible
...@@ -416,8 +510,7 @@ There are a few rules to get your merge request accepted: ...@@ -416,8 +510,7 @@ There are a few rules to get your merge request accepted:
1. If you need polling to support real-time features, please use 1. If you need polling to support real-time features, please use
[polling with ETag caching][polling-etag]. [polling with ETag caching][polling-etag].
1. Changes after submitting the merge request should be in separate commits 1. Changes after submitting the merge request should be in separate commits
(no squashing). If necessary, you will be asked to squash when the review is (no squashing).
over, before merging.
1. It conforms to the [style guides](#style-guides) and the following: 1. It conforms to the [style guides](#style-guides) and the following:
- If your change touches a line that does not follow the style, modify the - If your change touches a line that does not follow the style, modify the
entire line to follow it. This prevents linting tools from generating warnings. entire line to follow it. This prevents linting tools from generating warnings.
...@@ -428,19 +521,6 @@ There are a few rules to get your merge request accepted: ...@@ -428,19 +521,6 @@ There are a few rules to get your merge request accepted:
See the instructions in that document for help if your MR fails the See the instructions in that document for help if your MR fails the
"license-finder" test with a "Dependencies that need approval" error. "license-finder" test with a "Dependencies that need approval" error.
## Changes for Stable Releases
Sometimes certain changes have to be added to an existing stable release.
Two examples are bug fixes and performance improvements. In these cases the
corresponding merge request should be updated to have the following:
1. A milestone indicating what release the merge request should be merged into.
1. The label "Pick into Stable"
This makes it easier for release managers to keep track of what still has to be
merged and where changes have to be merged into.
Like all merge requests the target should be master so all bugfixes are in master.
## Definition of done ## Definition of done
If you contribute to GitLab please know that changes involve more than just If you contribute to GitLab please know that changes involve more than just
...@@ -449,16 +529,16 @@ the feature you contribute through all of these steps. ...@@ -449,16 +529,16 @@ the feature you contribute through all of these steps.
1. Description explaining the relevancy (see following item) 1. Description explaining the relevancy (see following item)
1. Working and clean code that is commented where needed 1. Working and clean code that is commented where needed
1. Unit and integration tests that pass on the CI server 1. [Unit and system tests][testing] that pass on the CI server
1. Performance/scalability implications have been considered, addressed, and tested 1. Performance/scalability implications have been considered, addressed, and tested
1. [Documented][doc-styleguide] in the /doc directory 1. [Documented][doc-styleguide] in the `/doc` directory
1. Changelog entry added 1. [Changelog entry added][changelog], if necessary
1. Reviewed and any concerns are addressed 1. Reviewed and any concerns are addressed
1. Merged by the project lead 1. Merged by a project maintainer
1. Added to the release blog article 1. Added to the release blog article, if relevant
1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/) if relevant 1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/), if relevant
1. Community questions answered 1. Community questions answered
1. Answers to questions radiated (in docs/wiki/etc.) 1. Answers to questions radiated (in docs/wiki/support etc.)
If you add a dependency in GitLab (such as an operating system package) please If you add a dependency in GitLab (such as an operating system package) please
consider updating the following and note the applicability of each in your consider updating the following and note the applicability of each in your
...@@ -481,7 +561,7 @@ merge request: ...@@ -481,7 +561,7 @@ merge request:
- string literal quoting style **Option A**: single quoted by default - string literal quoting style **Option A**: single quoted by default
1. [Rails](https://github.com/bbatsov/rails-style-guide) 1. [Rails](https://github.com/bbatsov/rails-style-guide)
1. [Newlines styleguide][newlines-styleguide] 1. [Newlines styleguide][newlines-styleguide]
1. [Testing](doc/development/testing.md) 1. [Testing][testing]
1. [JavaScript styleguide][js-styleguide] 1. [JavaScript styleguide][js-styleguide]
1. [SCSS styleguide][scss-styleguide] 1. [SCSS styleguide][scss-styleguide]
1. [Shell commands](doc/development/shell_commands.md) created by GitLab 1. [Shell commands](doc/development/shell_commands.md) created by GitLab
...@@ -558,6 +638,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor ...@@ -558,6 +638,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor
[license-finder-doc]: doc/development/licensing.md [license-finder-doc]: doc/development/licensing.md
[GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues [GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues
[polling-etag]: https://docs.gitlab.com/ce/development/polling.html [polling-etag]: https://docs.gitlab.com/ce/development/polling.html
[testing]: doc/development/testing.md
[^1]: Please note that specs other than JavaScript specs are considered backend [^1]: Please note that specs other than JavaScript specs are considered backend
code. code.
# GitLab Contributing Process ## GitLab Core Team & GitLab Inc. Contribution Process
---
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
- [Purpose of describing the contributing process](#purpose-of-describing-the-contributing-process)
- [Common actions](#common-actions)
- [Merge request coaching](#merge-request-coaching)
- [Assigning issues](#assigning-issues)
- [Be kind](#be-kind)
- [Feature freeze on the 7th for the release on the 22nd](#feature-freeze-on-the-7th-for-the-release-on-the-22nd)
- [Between the 1st and the 7th](#between-the-1st-and-the-7th)
- [On the 7th](#on-the-7th)
- [After the 7th](#after-the-7th)
- [Release retrospective and kickoff](#release-retrospective-and-kickoff)
- [Retrospective](#retrospective)
- [Kickoff](#kickoff)
- [Copy & paste responses](#copy--paste-responses)
- [Improperly formatted issue](#improperly-formatted-issue)
- [Issue report for old version](#issue-report-for-old-version)
- [Support requests and configuration questions](#support-requests-and-configuration-questions)
- [Code format](#code-format)
- [Issue fixed in newer version](#issue-fixed-in-newer-version)
- [Improperly formatted merge request](#improperly-formatted-merge-request)
- [Inactivity close of an issue](#inactivity-close-of-an-issue)
- [Inactivity close of a merge request](#inactivity-close-of-a-merge-request)
- [Accepting merge requests](#accepting-merge-requests)
- [Only accepting merge requests with green tests](#only-accepting-merge-requests-with-green-tests)
- [Closing down the issue tracker on GitHub](#closing-down-the-issue-tracker-on-github)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
---
## Purpose of describing the contributing process ## Purpose of describing the contributing process
Below we describe the contributing process to GitLab for two reasons. So that Below we describe the contributing process to GitLab for two reasons:
contributors know what to expect from maintainers (possible responses, friendly
treatment, etc.). And so that maintainers know what to expect from contributors 1. Contributors know what to expect from maintainers (possible responses, friendly
(use the latest version, ensure that the issue is addressed, friendly treatment, treatment, etc.)
etc.). 1. Maintainers know what to expect from contributors (use the latest version,
ensure that the issue is addressed, friendly treatment, etc.).
- [GitLab Inc engineers should refer to the engineering workflow document](https://about.gitlab.com/handbook/engineering/workflow/) - [GitLab Inc engineers should refer to the engineering workflow document](https://about.gitlab.com/handbook/engineering/workflow/)
## Common actions ## Common actions
### Issue triaging
Our issue triage policies are [described in our handbook]. You are very welcome
to help the GitLab team triage issues. We also organize [issue bash events] once
every quarter.
The most important thing is making sure valid issues receive feedback from the
development team. Therefore the priority is mentioning developers that can help
on those issues. Please select someone with relevant experience from
[GitLab team][team]. If there is nobody mentioned with that expertise
look in the commit history for the affected files to find someone. Avoid
mentioning the lead developer, this is the person that is least likely to give a
timely response. If the involvement of the lead developer is needed the other
core team members will mention this person.
[described in our handbook]: https://about.gitlab.com/handbook/engineering/issues/issue-triage-policies/
[issue bash events]: https://gitlab.com/gitlab-org/gitlab-ce/issues/17815
### Merge request coaching ### Merge request coaching
Several people from the [GitLab team][team] are helping community members to get Several people from the [GitLab team][team] are helping community members to get
...@@ -37,12 +55,6 @@ their contributions accepted by meeting our [Definition of done][done]. ...@@ -37,12 +55,6 @@ their contributions accepted by meeting our [Definition of done][done].
What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/. What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/.
## Workflow labels
Labelling issues is described in the [GitLab Inc engineering workflow].
[GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues
## Assigning issues ## Assigning issues
If an issue is complex and needs the attention of a specific person, assignment is a good option but assigning issues might discourage other people from contributing to that issue. We need all the contributions we can get so this should never be discouraged. Also, an assigned person might not have time for a few weeks, so others should feel free to takeover. If an issue is complex and needs the attention of a specific person, assignment is a good option but assigning issues might discourage other people from contributing to that issue. We need all the contributions we can get so this should never be discouraged. Also, an assigned person might not have time for a few weeks, so others should feel free to takeover.
...@@ -146,6 +158,29 @@ release should have the correct milestone assigned _and_ have the label ...@@ -146,6 +158,29 @@ release should have the correct milestone assigned _and_ have the label
Merge requests without a milestone and this label will Merge requests without a milestone and this label will
not be merged into any stable branches. not be merged into any stable branches.
## Release retrospective and kickoff
### Retrospective
After each release, we have a retrospective call where we discuss what went well,
what went wrong, and what we can improve for the next release. The
[retrospective notes] are public and you are invited to comment on them.
If you're interested, you can even join the
[retrospective call][retro-kickoff-call], on the first working day after the
22nd at 6pm CET / 9am PST.
### Kickoff
Before working on the next release, we have a
kickoff call to explain what we expect to ship in the next release. The
[kickoff notes] are public and you are invited to comment on them.
If you're interested, you can even join the [kickoff call][retro-kickoff-call],
on the first working day after the 7th at 6pm CET / 9am PST..
[retrospective notes]: https://docs.google.com/document/d/1nEkM_7Dj4bT21GJy0Ut3By76FZqCfLBmFQNVThmW2TY/edit?usp=sharing
[kickoff notes]: https://docs.google.com/document/d/1ElPkZ90A8ey_iOkTvUs_ByMlwKK6NAB2VOK5835wYK0/edit?usp=sharing
[retro-kickoff-call]: https://gitlab.zoom.us/j/918821206
## Copy & paste responses ## Copy & paste responses
### Improperly formatted issue ### Improperly formatted issue
......
...@@ -46,6 +46,7 @@ export default Vue.component('pipelines-table', { ...@@ -46,6 +46,7 @@ export default Vue.component('pipelines-table', {
isLoading: false, isLoading: false,
hasError: false, hasError: false,
isMakingRequest: false, isMakingRequest: false,
updateGraphDropdown: false,
}; };
}, },
...@@ -130,15 +131,21 @@ export default Vue.component('pipelines-table', { ...@@ -130,15 +131,21 @@ export default Vue.component('pipelines-table', {
const pipelines = response.pipelines || response; const pipelines = response.pipelines || response;
this.store.storePipelines(pipelines); this.store.storePipelines(pipelines);
this.isLoading = false; this.isLoading = false;
this.updateGraphDropdown = true;
}, },
errorCallback() { errorCallback() {
this.hasError = true; this.hasError = true;
this.isLoading = false; this.isLoading = false;
this.updateGraphDropdown = false;
}, },
setIsMakingRequest(isMakingRequest) { setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest; this.isMakingRequest = isMakingRequest;
if (isMakingRequest) {
this.updateGraphDropdown = false;
}
}, },
}, },
...@@ -163,7 +170,9 @@ export default Vue.component('pipelines-table', { ...@@ -163,7 +170,9 @@ export default Vue.component('pipelines-table', {
v-if="shouldRenderTable"> v-if="shouldRenderTable">
<pipelines-table-component <pipelines-table-component
:pipelines="state.pipelines" :pipelines="state.pipelines"
:service="service" /> :service="service"
:update-graph-dropdown="updateGraphDropdown"
/>
</div> </div>
</div> </div>
`, `,
......
<script>
import eventHub from '../eventhub';
export default {
data() {
return {
isLoading: false,
};
},
props: {
deployKey: {
type: Object,
required: true,
},
type: {
type: String,
required: true,
},
btnCssClass: {
type: String,
required: false,
default: 'btn-default',
},
},
methods: {
doAction() {
this.isLoading = true;
eventHub.$emit(`${this.type}.key`, this.deployKey);
},
},
computed: {
text() {
return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`;
},
},
};
</script>
<template>
<button
class="btn btn-sm prepend-left-10"
:class="[{ disabled: isLoading }, btnCssClass]"
:disabled="isLoading"
@click="doAction">
{{ text }}
<i
v-if="isLoading"
class="fa fa-spinner fa-spin"
aria-hidden="true"
aria-label="Loading">
</i>
</button>
</template>
<script>
/* global Flash */
import eventHub from '../eventhub';
import DeployKeysService from '../service';
import DeployKeysStore from '../store';
import keysPanel from './keys_panel.vue';
export default {
data() {
return {
isLoading: false,
store: new DeployKeysStore(),
};
},
props: {
endpoint: {
type: String,
required: true,
},
},
computed: {
hasKeys() {
return Object.keys(this.keys).length;
},
keys() {
return this.store.keys;
},
},
components: {
keysPanel,
},
methods: {
fetchKeys() {
this.isLoading = true;
this.service.getKeys()
.then((data) => {
this.isLoading = false;
this.store.keys = data;
})
.catch(() => new Flash('Error getting deploy keys'));
},
enableKey(deployKey) {
this.service.enableKey(deployKey.id)
.then(() => this.fetchKeys())
.catch(() => new Flash('Error enabling deploy key'));
},
disableKey(deployKey) {
// eslint-disable-next-line no-alert
if (confirm('You are going to remove this deploy key. Are you sure?')) {
this.service.disableKey(deployKey.id)
.then(() => this.fetchKeys())
.catch(() => new Flash('Error removing deploy key'));
}
},
},
created() {
this.service = new DeployKeysService(this.endpoint);
eventHub.$on('enable.key', this.enableKey);
eventHub.$on('remove.key', this.disableKey);
eventHub.$on('disable.key', this.disableKey);
},
mounted() {
this.fetchKeys();
},
beforeDestroy() {
eventHub.$off('enable.key', this.enableKey);
eventHub.$off('remove.key', this.disableKey);
eventHub.$off('disable.key', this.disableKey);
},
};
</script>
<template>
<div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys">
<div
class="text-center"
v-if="isLoading && !hasKeys">
<i
class="fa fa-spinner fa-spin fa-2x"
aria-hidden="true"
aria-label="Loading deploy keys">
</i>
</div>
<div v-else-if="hasKeys">
<keys-panel
title="Enabled deploy keys for this project"
:keys="keys.enabled_keys"
:store="store" />
<keys-panel
title="Deploy keys from projects you have access to"
:keys="keys.available_project_keys"
:store="store" />
<keys-panel
v-if="keys.public_keys.length"
title="Public deploy keys available to any project"
:keys="keys.public_keys"
:store="store" />
</div>
</div>
</template>
<script>
import actionBtn from './action_btn.vue';
export default {
props: {
deployKey: {
type: Object,
required: true,
},
store: {
type: Object,
required: true,
},
},
components: {
actionBtn,
},
computed: {
timeagoDate() {
return gl.utils.getTimeago().format(this.deployKey.created_at);
},
},
methods: {
isEnabled(id) {
return this.store.findEnabledKey(id) !== undefined;
},
},
};
</script>
<template>
<div>
<div class="pull-left append-right-10 hidden-xs">
<i
aria-hidden="true"
class="fa fa-key key-icon">
</i>
</div>
<div class="deploy-key-content key-list-item-info">
<strong class="title">
{{ deployKey.title }}
</strong>
<div class="description">
{{ deployKey.fingerprint }}
</div>
<div
v-if="deployKey.can_push"
class="write-access-allowed">
Write access allowed
</div>
</div>
<div class="deploy-key-content prepend-left-default deploy-key-projects">
<a
v-for="project in deployKey.projects"
class="label deploy-project-label"
:href="project.full_path">
{{ project.full_name }}
</a>
</div>
<div class="deploy-key-content">
<span class="key-created-at">
created {{ timeagoDate }}
</span>
<action-btn
v-if="!isEnabled(deployKey.id)"
:deploy-key="deployKey"
type="enable"/>
<action-btn
v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned"
:deploy-key="deployKey"
btn-css-class="btn-warning"
type="remove" />
<action-btn
v-else
:deploy-key="deployKey"
btn-css-class="btn-warning"
type="disable" />
</div>
</div>
</template>
<script>
import key from './key.vue';
export default {
props: {
title: {
type: String,
required: true,
},
keys: {
type: Array,
required: true,
},
showHelpBox: {
type: Boolean,
required: false,
default: true,
},
store: {
type: Object,
required: true,
},
},
components: {
key,
},
};
</script>
<template>
<div class="deploy-keys-panel">
<h5>
{{ title }}
({{ keys.length }})
</h5>
<ul class="well-list"
v-if="keys.length">
<li
v-for="deployKey in keys"
:key="deployKey.id">
<key
:deploy-key="deployKey"
:store="store" />
</li>
</ul>
<div
class="settings-message text-center"
v-else-if="showHelpBox">
No deploy keys found. Create one with the form above.
</div>
</div>
</template>
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import deployKeysApp from './components/app.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: document.getElementById('js-deploy-keys'),
data() {
return {
endpoint: this.$options.el.dataset.endpoint,
};
},
components: {
deployKeysApp,
},
render(createElement) {
return createElement('deploy-keys-app', {
props: {
endpoint: this.endpoint,
},
});
},
}));
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class DeployKeysService {
constructor(endpoint) {
this.endpoint = endpoint;
this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, {
enable: {
method: 'PUT',
url: `${this.endpoint}{/id}/enable`,
},
disable: {
method: 'PUT',
url: `${this.endpoint}{/id}/disable`,
},
});
}
getKeys() {
return this.resource.get()
.then(response => response.json());
}
enableKey(id) {
return this.resource.enable({ id }, {});
}
disableKey(id) {
return this.resource.disable({ id }, {});
}
}
export default class DeployKeysStore {
constructor() {
this.keys = {};
}
findEnabledKey(id) {
return this.keys.enabled_keys.find(key => key.id === id);
}
}
...@@ -50,6 +50,7 @@ import UserCallout from './user_callout'; ...@@ -50,6 +50,7 @@ import UserCallout from './user_callout';
import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
import ShortcutsWiki from './shortcuts_wiki'; import ShortcutsWiki from './shortcuts_wiki';
import BlobViewer from './blob/viewer/index'; import BlobViewer from './blob/viewer/index';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
const ShortcutsBlob = require('./shortcuts_blob'); const ShortcutsBlob = require('./shortcuts_blob');
...@@ -198,6 +199,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); ...@@ -198,6 +199,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
new LabelsSelect(); new LabelsSelect();
new MilestoneSelect(); new MilestoneSelect();
new gl.IssuableTemplateSelectors(); new gl.IssuableTemplateSelectors();
new AutoWidthDropdownSelect($('.js-target-branch-select')).init();
break; break;
case 'projects:tags:new': case 'projects:tags:new':
new ZenMode(); new ZenMode();
...@@ -344,6 +346,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); ...@@ -344,6 +346,9 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'projects:artifacts:browse': case 'projects:artifacts:browse':
new BuildArtifacts(); new BuildArtifacts();
break; break;
case 'projects:artifacts:file':
new BlobViewer();
break;
case 'help:index': case 'help:index':
gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
break; break;
......
...@@ -5,7 +5,7 @@ require('./preview_markdown'); ...@@ -5,7 +5,7 @@ require('./preview_markdown');
window.DropzoneInput = (function() { window.DropzoneInput = (function() {
function DropzoneInput(form) { function DropzoneInput(form) {
var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, project_uploads_path, showError, showSpinner, uploadFile, uploadProgress; var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, uploads_path, showError, showSpinner, uploadFile, uploadProgress;
Dropzone.autoDiscover = false; Dropzone.autoDiscover = false;
alertClass = "alert alert-danger alert-dismissable div-dropzone-alert"; alertClass = "alert alert-danger alert-dismissable div-dropzone-alert";
alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\""; alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\"";
...@@ -16,7 +16,7 @@ window.DropzoneInput = (function() { ...@@ -16,7 +16,7 @@ window.DropzoneInput = (function() {
iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>"; iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>";
uploadProgress = $("<div class=\"div-dropzone-progress\"></div>"); uploadProgress = $("<div class=\"div-dropzone-progress\"></div>");
btnAlert = "<button type=\"button\"" + alertAttr + ">&times;</button>"; btnAlert = "<button type=\"button\"" + alertAttr + ">&times;</button>";
project_uploads_path = window.project_uploads_path || null; uploads_path = window.uploads_path || null;
max_file_size = gon.max_file_size || 10; max_file_size = gon.max_file_size || 10;
form_textarea = $(form).find(".js-gfm-input"); form_textarea = $(form).find(".js-gfm-input");
form_textarea.wrap("<div class=\"div-dropzone\"></div>"); form_textarea.wrap("<div class=\"div-dropzone\"></div>");
...@@ -39,10 +39,10 @@ window.DropzoneInput = (function() { ...@@ -39,10 +39,10 @@ window.DropzoneInput = (function() {
"display": "none" "display": "none"
}); });
if (!project_uploads_path) return; if (!uploads_path) return;
dropzone = form_dropzone.dropzone({ dropzone = form_dropzone.dropzone({
url: project_uploads_path, url: uploads_path,
dictDefaultMessage: "", dictDefaultMessage: "",
clickable: true, clickable: true,
paramName: "file", paramName: "file",
...@@ -159,7 +159,7 @@ window.DropzoneInput = (function() { ...@@ -159,7 +159,7 @@ window.DropzoneInput = (function() {
formData = new FormData(); formData = new FormData();
formData.append("file", item, filename); formData.append("file", item, filename);
return $.ajax({ return $.ajax({
url: project_uploads_path, url: uploads_path,
type: "POST", type: "POST",
data: formData, data: formData,
dataType: "json", dataType: "json",
......
<script> <script>
/* eslint-disable no-new */
/* global Flash */ /* global Flash */
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from './environments_table.vue'; import EnvironmentTable from './environments_table.vue';
...@@ -71,11 +69,13 @@ export default { ...@@ -71,11 +69,13 @@ export default {
eventHub.$on('refreshEnvironments', this.fetchEnvironments); eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder); eventHub.$on('toggleFolder', this.toggleFolder);
eventHub.$on('postAction', this.postAction);
}, },
beforeDestroyed() { beforeDestroyed() {
eventHub.$off('refreshEnvironments'); eventHub.$off('refreshEnvironments');
eventHub.$off('toggleFolder'); eventHub.$off('toggleFolder');
eventHub.$off('postAction');
}, },
methods: { methods: {
...@@ -122,6 +122,7 @@ export default { ...@@ -122,6 +122,7 @@ export default {
}) })
.catch(() => { .catch(() => {
this.isLoading = false; this.isLoading = false;
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.'); new Flash('An error occurred while fetching the environments.');
}); });
}, },
...@@ -137,9 +138,16 @@ export default { ...@@ -137,9 +138,16 @@ export default {
}) })
.catch(() => { .catch(() => {
this.isLoadingFolderContent = false; this.isLoadingFolderContent = false;
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.'); new Flash('An error occurred while fetching the environments.');
}); });
}, },
postAction(endpoint) {
this.service.postAction(endpoint)
.then(() => this.fetchEnvironments())
.catch(() => new Flash('An error occured while making the request.'));
},
}, },
}; };
</script> </script>
...@@ -217,7 +225,6 @@ export default { ...@@ -217,7 +225,6 @@ export default {
:environments="state.environments" :environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed" :can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed" :can-read-environment="canReadEnvironmentParsed"
:service="service"
:is-loading-folder-content="isLoadingFolderContent" /> :is-loading-folder-content="isLoadingFolderContent" />
</div> </div>
......
<script> <script>
/* global Flash */
/* eslint-disable no-new */
import playIconSvg from 'icons/_icon_play.svg'; import playIconSvg from 'icons/_icon_play.svg';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
...@@ -12,11 +9,6 @@ export default { ...@@ -12,11 +9,6 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
service: {
type: Object,
required: true,
},
}, },
data() { data() {
...@@ -38,15 +30,7 @@ export default { ...@@ -38,15 +30,7 @@ export default {
$(this.$refs.tooltip).tooltip('destroy'); $(this.$refs.tooltip).tooltip('destroy');
this.service.postAction(endpoint) eventHub.$emit('postAction', endpoint);
.then(() => {
this.isLoading = false;
eventHub.$emit('refreshEnvironments');
})
.catch(() => {
this.isLoading = false;
new Flash('An error occured while making the request.');
});
}, },
isActionDisabled(action) { isActionDisabled(action) {
......
...@@ -46,11 +46,6 @@ export default { ...@@ -46,11 +46,6 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
service: {
type: Object,
required: true,
},
}, },
computed: { computed: {
...@@ -543,31 +538,34 @@ export default { ...@@ -543,31 +538,34 @@ export default {
<actions-component <actions-component
v-if="hasManualActions && canCreateDeployment" v-if="hasManualActions && canCreateDeployment"
:service="service" :actions="manualActions"
:actions="manualActions"/> />
<external-url-component <external-url-component
v-if="externalURL && canReadEnvironment" v-if="externalURL && canReadEnvironment"
:external-url="externalURL"/> :external-url="externalURL"
/>
<monitoring-button-component <monitoring-button-component
v-if="monitoringUrl && canReadEnvironment" v-if="monitoringUrl && canReadEnvironment"
:monitoring-url="monitoringUrl"/> :monitoring-url="monitoringUrl"
/>
<terminal-button-component <terminal-button-component
v-if="model && model.terminal_path" v-if="model && model.terminal_path"
:terminal-path="model.terminal_path"/> :terminal-path="model.terminal_path"
/>
<stop-component <stop-component
v-if="hasStopAction && canCreateDeployment" v-if="hasStopAction && canCreateDeployment"
:stop-url="model.stop_path" :stop-url="model.stop_path"
:service="service"/> />
<rollback-component <rollback-component
v-if="canRetry && canCreateDeployment" v-if="canRetry && canCreateDeployment"
:is-last-deployment="isLastDeployment" :is-last-deployment="isLastDeployment"
:retry-url="retryUrl" :retry-url="retryUrl"
:service="service"/> />
</div> </div>
</td> </td>
</tr> </tr>
......
<script> <script>
/* global Flash */
/* eslint-disable no-new */
/** /**
* Renders Rollback or Re deploy button in environments table depending * Renders Rollback or Re deploy button in environments table depending
* of the provided property `isLastDeployment`. * of the provided property `isLastDeployment`.
...@@ -20,11 +18,6 @@ export default { ...@@ -20,11 +18,6 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
service: {
type: Object,
required: true,
},
}, },
data() { data() {
...@@ -37,17 +30,7 @@ export default { ...@@ -37,17 +30,7 @@ export default {
onClick() { onClick() {
this.isLoading = true; this.isLoading = true;
$(this.$el).tooltip('destroy'); eventHub.$emit('postAction', this.retryUrl);
this.service.postAction(this.retryUrl)
.then(() => {
this.isLoading = false;
eventHub.$emit('refreshEnvironments');
})
.catch(() => {
this.isLoading = false;
new Flash('An error occured while making the request.');
});
}, },
}, },
}; };
......
<script> <script>
/* global Flash */
/* eslint-disable no-new, no-alert */
/** /**
* Renders the stop "button" that allows stop an environment. * Renders the stop "button" that allows stop an environment.
* Used in environments table. * Used in environments table.
...@@ -13,11 +11,6 @@ export default { ...@@ -13,11 +11,6 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
service: {
type: Object,
required: true,
},
}, },
data() { data() {
...@@ -34,20 +27,13 @@ export default { ...@@ -34,20 +27,13 @@ export default {
methods: { methods: {
onClick() { onClick() {
// eslint-disable-next-line no-alert
if (confirm('Are you sure you want to stop this environment?')) { if (confirm('Are you sure you want to stop this environment?')) {
this.isLoading = true; this.isLoading = true;
$(this.$el).tooltip('destroy'); $(this.$el).tooltip('destroy');
this.service.postAction(this.retryUrl) eventHub.$emit('postAction', this.stopUrl);
.then(() => {
this.isLoading = false;
eventHub.$emit('refreshEnvironments');
})
.catch(() => {
this.isLoading = false;
new Flash('An error occured while making the request.', 'alert');
});
} }
}, },
}, },
......
...@@ -28,11 +28,6 @@ export default { ...@@ -28,11 +28,6 @@ export default {
default: false, default: false,
}, },
service: {
type: Object,
required: true,
},
isLoadingFolderContent: { isLoadingFolderContent: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -78,7 +73,7 @@ export default { ...@@ -78,7 +73,7 @@ export default {
:model="model" :model="model"
:can-create-deployment="canCreateDeployment" :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment" :can-read-environment="canReadEnvironment"
:service="service" /> />
<template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
<tr v-if="isLoadingFolderContent"> <tr v-if="isLoadingFolderContent">
...@@ -96,7 +91,7 @@ export default { ...@@ -96,7 +91,7 @@ export default {
:model="children" :model="children"
:can-create-deployment="canCreateDeployment" :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment" :can-read-environment="canReadEnvironment"
:service="service" /> />
<tr> <tr>
<td <td
......
<script> <script>
/* eslint-disable no-new */
/* global Flash */ /* global Flash */
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from '../components/environments_table.vue'; import EnvironmentTable from '../components/environments_table.vue';
...@@ -99,6 +98,7 @@ export default { ...@@ -99,6 +98,7 @@ export default {
}) })
.catch(() => { .catch(() => {
this.isLoading = false; this.isLoading = false;
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.', 'alert'); new Flash('An error occurred while fetching the environments.', 'alert');
}); });
}, },
...@@ -169,7 +169,7 @@ export default { ...@@ -169,7 +169,7 @@ export default {
:environments="state.environments" :environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed" :can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed" :can-read-environment="canReadEnvironmentParsed"
:service="service"/> />
<table-pagination <table-pagination
v-if="state.paginationInformation && state.paginationInformation.totalPages > 1" v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
......
...@@ -101,9 +101,17 @@ window.gl.GfmAutoComplete = { ...@@ -101,9 +101,17 @@ window.gl.GfmAutoComplete = {
} }
} }
}, },
setup: function(input) { setup: function(input, enableMap = {
emojis: true,
members: true,
issues: true,
milestones: true,
mergeRequests: true,
labels: true
}) {
// Add GFM auto-completion to all input fields, that accept GFM input. // Add GFM auto-completion to all input fields, that accept GFM input.
this.input = input || $('.js-gfm-input'); this.input = input || $('.js-gfm-input');
this.enableMap = enableMap;
this.setupLifecycle(); this.setupLifecycle();
}, },
setupLifecycle() { setupLifecycle() {
...@@ -115,7 +123,84 @@ window.gl.GfmAutoComplete = { ...@@ -115,7 +123,84 @@ window.gl.GfmAutoComplete = {
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
}); });
}, },
setupAtWho: function($input) { setupAtWho: function($input) {
if (this.enableMap.emojis) this.setupEmoji($input);
if (this.enableMap.members) this.setupMembers($input);
if (this.enableMap.issues) this.setupIssues($input);
if (this.enableMap.milestones) this.setupMilestones($input);
if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
if (this.enableMap.labels) this.setupLabels($input);
// We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
$input.filter('[data-supports-slash-commands="true"]').atwho({
at: '/',
alias: 'commands',
searchKey: 'search',
skipSpecialCharacterTest: true,
data: this.defaultLoadingData,
displayTpl: function(value) {
if (this.isLoading(value)) return this.Loading.template;
var tpl = '<li>/${name}';
if (value.aliases.length > 0) {
tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
}
if (value.params.length > 0) {
tpl += ' <small><%- params.join(" ") %></small>';
}
if (value.description !== '') {
tpl += '<small class="description"><i><%- description %></i></small>';
}
tpl += '</li>';
return _.template(tpl)(value);
}.bind(this),
insertTpl: function(value) {
var tpl = "/${name} ";
var reference_prefix = null;
if (value.params.length > 0) {
reference_prefix = value.params[0][0];
if (/^[@%~]/.test(reference_prefix)) {
tpl += '<%- reference_prefix %>';
}
}
return _.template(tpl)({ reference_prefix: reference_prefix });
},
suffix: '',
callbacks: {
sorter: this.DefaultOptions.sorter,
filter: this.DefaultOptions.filter,
beforeInsert: this.DefaultOptions.beforeInsert,
beforeSave: function(commands) {
if (gl.GfmAutoComplete.isLoading(commands)) return commands;
return $.map(commands, function(c) {
var search = c.name;
if (c.aliases.length > 0) {
search = search + " " + c.aliases.join(" ");
}
return {
name: c.name,
aliases: c.aliases,
params: c.params,
description: c.description,
search: search
};
});
},
matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
var match = regexp.exec(subtext);
if (match) {
return match[1];
} else {
return null;
}
}
}
});
return;
},
setupEmoji($input) {
// Emoji // Emoji
$input.atwho({ $input.atwho({
at: ':', at: ':',
...@@ -139,6 +224,9 @@ window.gl.GfmAutoComplete = { ...@@ -139,6 +224,9 @@ window.gl.GfmAutoComplete = {
} }
} }
}); });
},
setupMembers($input) {
// Team Members // Team Members
$input.atwho({ $input.atwho({
at: '@', at: '@',
...@@ -180,6 +268,9 @@ window.gl.GfmAutoComplete = { ...@@ -180,6 +268,9 @@ window.gl.GfmAutoComplete = {
} }
} }
}); });
},
setupIssues($input) {
$input.atwho({ $input.atwho({
at: '#', at: '#',
alias: 'issues', alias: 'issues',
...@@ -208,6 +299,9 @@ window.gl.GfmAutoComplete = { ...@@ -208,6 +299,9 @@ window.gl.GfmAutoComplete = {
} }
} }
}); });
},
setupMilestones($input) {
$input.atwho({ $input.atwho({
at: '%', at: '%',
alias: 'milestones', alias: 'milestones',
...@@ -236,6 +330,9 @@ window.gl.GfmAutoComplete = { ...@@ -236,6 +330,9 @@ window.gl.GfmAutoComplete = {
} }
} }
}); });
},
setupMergeRequests($input) {
$input.atwho({ $input.atwho({
at: '!', at: '!',
alias: 'mergerequests', alias: 'mergerequests',
...@@ -264,6 +361,9 @@ window.gl.GfmAutoComplete = { ...@@ -264,6 +361,9 @@ window.gl.GfmAutoComplete = {
} }
} }
}); });
},
setupLabels($input) {
$input.atwho({ $input.atwho({
at: '~', at: '~',
alias: 'labels', alias: 'labels',
...@@ -298,73 +398,8 @@ window.gl.GfmAutoComplete = { ...@@ -298,73 +398,8 @@ window.gl.GfmAutoComplete = {
} }
} }
}); });
// We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
$input.filter('[data-supports-slash-commands="true"]').atwho({
at: '/',
alias: 'commands',
searchKey: 'search',
skipSpecialCharacterTest: true,
data: this.defaultLoadingData,
displayTpl: function(value) {
if (this.isLoading(value)) return this.Loading.template;
var tpl = '<li>/${name}';
if (value.aliases.length > 0) {
tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
}
if (value.params.length > 0) {
tpl += ' <small><%- params.join(" ") %></small>';
}
if (value.description !== '') {
tpl += '<small class="description"><i><%- description %></i></small>';
}
tpl += '</li>';
return _.template(tpl)(value);
}.bind(this),
insertTpl: function(value) {
var tpl = "/${name} ";
var reference_prefix = null;
if (value.params.length > 0) {
reference_prefix = value.params[0][0];
if (/^[@%~]/.test(reference_prefix)) {
tpl += '<%- reference_prefix %>';
}
}
return _.template(tpl)({ reference_prefix: reference_prefix });
},
suffix: '',
callbacks: {
sorter: this.DefaultOptions.sorter,
filter: this.DefaultOptions.filter,
beforeInsert: this.DefaultOptions.beforeInsert,
beforeSave: function(commands) {
if (gl.GfmAutoComplete.isLoading(commands)) return commands;
return $.map(commands, function(c) {
var search = c.name;
if (c.aliases.length > 0) {
search = search + " " + c.aliases.join(" ");
}
return {
name: c.name,
aliases: c.aliases,
params: c.params,
description: c.description,
search: search
};
});
},
matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
var match = regexp.exec(subtext);
if (match) {
return match[1];
} else {
return null;
}
}
}
});
return;
}, },
fetchData: function($input, at) { fetchData: function($input, at) {
if (this.isLoadingData[at]) return; if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true; this.isLoadingData[at] = true;
......
let instanceCount = 0;
class AutoWidthDropdownSelect {
constructor(selectElement) {
this.$selectElement = $(selectElement);
this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`;
instanceCount += 1;
}
init() {
const dropdownClass = this.dropdownClass;
this.$selectElement.select2({
dropdownCssClass: dropdownClass,
dropdownCss() {
let resultantWidth = 'auto';
const $dropdown = $(`.${dropdownClass}`);
// We have to look at the parent because
// `offsetParent` on a `display: none;` is `null`
const offsetParentWidth = $(this).parent().offsetParent().width();
// Reset any width to let it naturally flow
$dropdown.css('width', 'auto');
if ($dropdown.outerWidth(false) > offsetParentWidth) {
resultantWidth = offsetParentWidth;
}
return {
width: resultantWidth,
maxWidth: offsetParentWidth,
};
},
});
return this;
}
}
export default AutoWidthDropdownSelect;
...@@ -34,6 +34,7 @@ ...@@ -34,6 +34,7 @@
filterByText: true, filterByText: true,
remote: false, remote: false,
fieldName: $branchSelect.data('field-name'), fieldName: $branchSelect.data('field-name'),
filterInput: 'input[type="search"]',
selectable: true, selectable: true,
isSelectable: function(branch, $el) { isSelectable: function(branch, $el) {
return !$el.hasClass('is-active'); return !$el.hasClass('is-active');
...@@ -50,6 +51,21 @@ ...@@ -50,6 +51,21 @@
} }
} }
}); });
const $dropdownContainer = $branchSelect.closest('.dropdown');
const $fieldInput = $(`input[name="${$branchSelect.data('field-name')}"]`, $dropdownContainer);
const $filterInput = $('input[type="search"]', $dropdownContainer);
$filterInput.on('keyup', (e) => {
const keyCode = e.keyCode || e.which;
if (keyCode !== 13) return;
const text = $filterInput.val();
$fieldInput.val(text);
$('.dropdown-toggle-text', $branchSelect).text(text);
$dropdownContainer.removeClass('open');
});
}; };
NewBranchForm.prototype.setupRestrictions = function() { NewBranchForm.prototype.setupRestrictions = function() {
......
<script>
/**
* Renders each stage of the pipeline mini graph.
*
* Given the provided endpoint will make a request to
* fetch the dropdown data when the stage is clicked.
*
* Request is made inside this component to make it reusable between:
* 1. Pipelines main table
* 2. Pipelines table in commit and Merge request views
* 3. Merge request widget
* 4. Commit widget
*/
/* global Flash */ /* global Flash */
import StatusIconEntityMap from '../../ci_status_icons'; import StatusIconEntityMap from '../../ci_status_icons';
...@@ -7,36 +22,55 @@ export default { ...@@ -7,36 +22,55 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
updateDropdown: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
builds: '', isLoading: false,
spinner: '<span class="fa fa-spinner fa-spin"></span>', dropdownContent: '',
endpoint: this.stage.dropdown_path,
}; };
}, },
updated() { updated() {
if (this.builds) { if (this.dropdownContent.length > 0) {
this.stopDropdownClickPropagation(); this.stopDropdownClickPropagation();
} }
}, },
methods: { watch: {
fetchBuilds(e) { updateDropdown() {
const ariaExpanded = e.currentTarget.attributes['aria-expanded']; if (this.updateDropdown &&
this.isDropdownOpen() &&
!this.isLoading) {
this.fetchJobs();
}
},
},
if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null; methods: {
onClickStage() {
if (!this.isDropdownOpen()) {
this.isLoading = true;
this.fetchJobs();
}
},
return this.$http.get(this.stage.dropdown_path) fetchJobs() {
this.$http.get(this.endpoint)
.then((response) => { .then((response) => {
this.builds = JSON.parse(response.body).html; this.dropdownContent = response.json().html;
this.isLoading = false;
}) })
.catch(() => { .catch(() => {
// If dropdown is opened we'll close it. this.closeDropdown();
if (this.$el.classList.contains('open')) { this.isLoading = false;
$(this.$refs.dropdown).dropdown('toggle');
}
const flash = new Flash('Something went wrong on our end.'); const flash = new Flash('Something went wrong on our end.');
return flash; return flash;
...@@ -57,59 +91,83 @@ export default { ...@@ -57,59 +91,83 @@ export default {
e.stopPropagation(); e.stopPropagation();
}); });
}, },
closeDropdown() {
if (this.isDropdownOpen()) {
$(this.$refs.dropdown).dropdown('toggle');
}
},
isDropdownOpen() {
return this.$el.classList.contains('open');
},
}, },
computed: { computed: {
buildsOrSpinner() {
return this.builds ? this.builds : this.spinner;
},
dropdownClass() { dropdownClass() {
if (this.builds) return 'js-builds-dropdown-container'; return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
return 'js-builds-dropdown-loading builds-dropdown-loading';
},
buildStatus() {
return `Build: ${this.stage.status.label}`;
},
tooltip() {
return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
}, },
triggerButtonClass() { triggerButtonClass() {
return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; return `ci-status-icon-${this.stage.status.group}`;
}, },
svgHTML() {
svgIcon() {
return StatusIconEntityMap[this.stage.status.icon]; return StatusIconEntityMap[this.stage.status.icon];
}, },
}, },
template: ` };
<div> </script>
<button
@click="fetchBuilds($event)" <template>
:class="triggerButtonClass" <div class="dropdown">
:title="stage.title" <button
data-placement="top" :class="triggerButtonClass"
data-toggle="dropdown" @click="onClickStage"
type="button" class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button"
:aria-label="stage.title" :title="stage.title"
ref="dropdown"> data-placement="top"
<span data-toggle="dropdown"
v-html="svgHTML" type="button"
aria-hidden="true"> id="stageDropdown"
</span> aria-haspopup="true"
<i aria-expanded="false">
class="fa fa-caret-down"
aria-hidden="true" /> <span
</button> v-html="svgIcon"
<ul aria-hidden="true"
ref="dropdown-content" :aria-label="stage.title">
class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> </span>
<div
class="arrow-up" <i
aria-hidden="true"></div> class="fa fa-caret-down"
aria-hidden="true">
</i>
</button>
<ul
class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"
aria-labelledby="stageDropdown">
<li
:class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu">
<div <div
:class="dropdownClass" class="text-center"
class="js-builds-dropdown-list scrollable-menu" v-if="isLoading">
v-html="buildsOrSpinner"> <i
class="fa fa-spin fa-spinner"
aria-hidden="true"
aria-label="Loading">
</i>
</div> </div>
</ul>
</div> <ul
`, v-else
}; v-html="dropdownContent">
</ul>
</li>
</ul>
</div>
</script>
...@@ -49,6 +49,7 @@ export default { ...@@ -49,6 +49,7 @@ export default {
isLoading: false, isLoading: false,
hasError: false, hasError: false,
isMakingRequest: false, isMakingRequest: false,
updateGraphDropdown: false,
}; };
}, },
...@@ -198,15 +199,21 @@ export default { ...@@ -198,15 +199,21 @@ export default {
this.store.storePagination(response.headers); this.store.storePagination(response.headers);
this.isLoading = false; this.isLoading = false;
this.updateGraphDropdown = true;
}, },
errorCallback() { errorCallback() {
this.hasError = true; this.hasError = true;
this.isLoading = false; this.isLoading = false;
this.updateGraphDropdown = false;
}, },
setIsMakingRequest(isMakingRequest) { setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest; this.isMakingRequest = isMakingRequest;
if (isMakingRequest) {
this.updateGraphDropdown = false;
}
}, },
}, },
...@@ -263,7 +270,9 @@ export default { ...@@ -263,7 +270,9 @@ export default {
<pipelines-table-component <pipelines-table-component
:pipelines="state.pipelines" :pipelines="state.pipelines"
:service="service"/> :service="service"
:update-graph-dropdown="updateGraphDropdown"
/>
</div> </div>
<gl-pagination <gl-pagination
......
...@@ -10,13 +10,18 @@ export default { ...@@ -10,13 +10,18 @@ export default {
pipelines: { pipelines: {
type: Array, type: Array,
required: true, required: true,
default: () => ([]),
}, },
service: { service: {
type: Object, type: Object,
required: true, required: true,
}, },
updateGraphDropdown: {
type: Boolean,
required: false,
default: false,
},
}, },
components: { components: {
...@@ -40,7 +45,9 @@ export default { ...@@ -40,7 +45,9 @@ export default {
v-bind:model="model"> v-bind:model="model">
<tr is="pipelines-table-row-component" <tr is="pipelines-table-row-component"
:pipeline="model" :pipeline="model"
:service="service"></tr> :service="service"
:update-graph-dropdown="updateGraphDropdown"
/>
</template> </template>
</tbody> </tbody>
</table> </table>
......
...@@ -3,7 +3,7 @@ import AsyncButtonComponent from '../../pipelines/components/async_button.vue'; ...@@ -3,7 +3,7 @@ import AsyncButtonComponent from '../../pipelines/components/async_button.vue';
import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions'; import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions';
import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
import PipelinesStatusComponent from '../../pipelines/components/status'; import PipelinesStatusComponent from '../../pipelines/components/status';
import PipelinesStageComponent from '../../pipelines/components/stage'; import PipelinesStageComponent from '../../pipelines/components/stage.vue';
import PipelinesUrlComponent from '../../pipelines/components/pipeline_url'; import PipelinesUrlComponent from '../../pipelines/components/pipeline_url';
import PipelinesTimeagoComponent from '../../pipelines/components/time_ago'; import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
import CommitComponent from './commit'; import CommitComponent from './commit';
...@@ -24,6 +24,12 @@ export default { ...@@ -24,6 +24,12 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
updateGraphDropdown: {
type: Boolean,
required: false,
default: false,
},
}, },
components: { components: {
...@@ -213,7 +219,10 @@ export default { ...@@ -213,7 +219,10 @@ export default {
<div class="stage-container dropdown js-mini-pipeline-graph" <div class="stage-container dropdown js-mini-pipeline-graph"
v-if="pipeline.details.stages.length > 0" v-if="pipeline.details.stages.length > 0"
v-for="stage in pipeline.details.stages"> v-for="stage in pipeline.details.stages">
<dropdown-stage :stage="stage"/>
<dropdown-stage
:stage="stage"
:update-dropdown="updateGraphDropdown"/>
</div> </div>
</td> </td>
......
...@@ -482,6 +482,10 @@ ...@@ -482,6 +482,10 @@
} }
} }
.target-branch-select-dropdown-container {
position: relative;
}
.assign-to-me-link { .assign-to-me-link {
padding-left: 12px; padding-left: 12px;
white-space: nowrap; white-space: nowrap;
......
...@@ -781,16 +781,11 @@ ...@@ -781,16 +781,11 @@
} }
.scrollable-menu { .scrollable-menu {
padding: 0;
max-height: 245px; max-height: 245px;
overflow: auto; overflow: auto;
} }
// Loading icon
.builds-dropdown-loading {
margin: 0 auto;
width: 20px;
}
// Action icon on the right // Action icon on the right
a.ci-action-icon-wrapper { a.ci-action-icon-wrapper {
color: $action-icon-color; color: $action-icon-color;
...@@ -893,30 +888,29 @@ ...@@ -893,30 +888,29 @@
* Top arrow in the dropdown in the mini pipeline graph * Top arrow in the dropdown in the mini pipeline graph
*/ */
.mini-pipeline-graph-dropdown-menu { .mini-pipeline-graph-dropdown-menu {
.arrow-up {
&::before,
&::after {
content: '';
display: inline-block;
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
top: -6px;
left: 2px;
border-width: 0 5px 6px;
}
&::before { &::before,
border-width: 0 5px 5px; &::after {
border-bottom-color: $border-color; content: '';
} display: inline-block;
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
top: -6px;
left: 2px;
border-width: 0 5px 6px;
}
&::after { &::before {
margin-top: 1px; border-width: 0 5px 5px;
border-bottom-color: $white-light; border-bottom-color: $border-color;
} }
&::after {
margin-top: 1px;
border-bottom-color: $white-light;
} }
} }
......
module UploadsActions
def create
link_to_file = UploadService.new(model, params[:file], uploader_class).execute
respond_to do |format|
if link_to_file
format.json do
render json: { link: link_to_file }
end
else
format.json do
render json: 'Invalid file.', status: :unprocessable_entity
end
end
end
end
def show
return render_404 unless uploader.exists?
disposition = uploader.image_or_video? ? 'inline' : 'attachment'
expires_in 0.seconds, must_revalidate: true, private: true
send_file uploader.file.path, disposition: disposition
end
end
class Projects::ArtifactsController < Projects::ApplicationController class Projects::ArtifactsController < Projects::ApplicationController
include ExtractsPath include ExtractsPath
include RendersBlob
layout 'project' layout 'project'
before_action :authorize_read_build! before_action :authorize_read_build!
before_action :authorize_update_build!, only: [:keep] before_action :authorize_update_build!, only: [:keep]
before_action :extract_ref_name_and_path before_action :extract_ref_name_and_path
before_action :validate_artifacts! before_action :validate_artifacts!
before_action :set_path_and_entry, only: [:file, :raw]
def download def download
if artifacts_file.file_storage? if artifacts_file.file_storage?
...@@ -24,15 +26,24 @@ class Projects::ArtifactsController < Projects::ApplicationController ...@@ -24,15 +26,24 @@ class Projects::ArtifactsController < Projects::ApplicationController
end end
def file def file
entry = build.artifacts_metadata_entry(params[:path]) blob = @entry.blob
override_max_blob_size(blob)
if entry.exists? respond_to do |format|
send_artifacts_entry(build, entry) format.html do
else render 'file'
render_404 end
format.json do
render_blob_json(blob)
end
end end
end end
def raw
send_artifacts_entry(build, @entry)
end
def keep def keep
build.keep_artifacts! build.keep_artifacts!
redirect_to namespace_project_build_path(project.namespace, project, build) redirect_to namespace_project_build_path(project.namespace, project, build)
...@@ -81,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController ...@@ -81,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController
def artifacts_file def artifacts_file
@artifacts_file ||= build.artifacts_file @artifacts_file ||= build.artifacts_file
end end
def set_path_and_entry
@path = params[:path]
@entry = build.artifacts_metadata_entry(@path)
render_404 unless @entry.exists?
end
end end
...@@ -8,7 +8,12 @@ class Projects::DeployKeysController < Projects::ApplicationController ...@@ -8,7 +8,12 @@ class Projects::DeployKeysController < Projects::ApplicationController
layout "project_settings" layout "project_settings"
def index def index
redirect_to_repository_settings(@project) respond_to do |format|
format.html { redirect_to_repository_settings(@project) }
format.json do
render json: Projects::Settings::DeployKeysPresenter.new(@project, current_user: current_user).as_json
end
end
end end
def new def new
...@@ -19,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController ...@@ -19,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
@key = DeployKey.new(deploy_key_params.merge(user: current_user)) @key = DeployKey.new(deploy_key_params.merge(user: current_user))
unless @key.valid? && @project.deploy_keys << @key unless @key.valid? && @project.deploy_keys << @key
flash[:alert] = @key.errors.full_messages.join(', ').html_safe flash[:alert] = @key.errors.full_messages.join(', ').html_safe
end end
redirect_to_repository_settings(@project) redirect_to_repository_settings(@project)
end end
...@@ -27,7 +32,10 @@ class Projects::DeployKeysController < Projects::ApplicationController ...@@ -27,7 +32,10 @@ class Projects::DeployKeysController < Projects::ApplicationController
def enable def enable
Projects::EnableDeployKeyService.new(@project, current_user, params).execute Projects::EnableDeployKeyService.new(@project, current_user, params).execute
redirect_to_repository_settings(@project) respond_to do |format|
format.html { redirect_to_repository_settings(@project) }
format.json { head :ok }
end
end end
def disable def disable
...@@ -35,7 +43,11 @@ class Projects::DeployKeysController < Projects::ApplicationController ...@@ -35,7 +43,11 @@ class Projects::DeployKeysController < Projects::ApplicationController
return render_404 unless deploy_key_project return render_404 unless deploy_key_project
deploy_key_project.destroy! deploy_key_project.destroy!
redirect_to_repository_settings(@project)
respond_to do |format|
format.html { redirect_to_repository_settings(@project) }
format.json { head :ok }
end
end end
protected protected
......
class Projects::UploadsController < Projects::ApplicationController class Projects::UploadsController < Projects::ApplicationController
include UploadsActions
skip_before_action :project, :repository, skip_before_action :project, :repository,
if: -> { action_name == 'show' && image_or_video? } if: -> { action_name == 'show' && image_or_video? }
before_action :authorize_upload_file!, only: [:create] before_action :authorize_upload_file!, only: [:create]
def create
link_to_file = ::Projects::UploadService.new(project, params[:file]).
execute
respond_to do |format|
if link_to_file
format.json do
render json: { link: link_to_file }
end
else
format.json do
render json: 'Invalid file.', status: :unprocessable_entity
end
end
end
end
def show
return render_404 if uploader.nil? || !uploader.file.exists?
disposition = uploader.image_or_video? ? 'inline' : 'attachment'
send_file uploader.file.path, disposition: disposition
end
private private
def uploader def uploader
...@@ -52,4 +30,10 @@ class Projects::UploadsController < Projects::ApplicationController ...@@ -52,4 +30,10 @@ class Projects::UploadsController < Projects::ApplicationController
def image_or_video? def image_or_video?
uploader && uploader.file.exists? && uploader.image_or_video? uploader && uploader.file.exists? && uploader.image_or_video?
end end
def uploader_class
FileUploader
end
alias_method :model, :project
end end
class UploadsController < ApplicationController class UploadsController < ApplicationController
skip_before_action :authenticate_user! include UploadsActions
before_action :find_model, :authorize_access!
def show
uploader = @model.send(upload_mount)
unless uploader.file_storage?
return redirect_to uploader.url
end
unless uploader.file && uploader.file.exists? skip_before_action :authenticate_user!
return render_404 before_action :find_model
end before_action :authorize_access!, only: [:show]
before_action :authorize_create_access!, only: [:create]
disposition = uploader.image? ? 'inline' : 'attachment'
expires_in 0.seconds, must_revalidate: true, private: true
send_file uploader.file.path, disposition: disposition
end
private private
def find_model def find_model
unless upload_model && upload_mount return render_404 unless upload_model && upload_mount
return render_404
end
@model = upload_model.find(params[:id]) @model = upload_model.find(params[:id])
end end
def authorize_access! def authorize_access!
authorized = authorized =
case @model case model
when Project
can?(current_user, :read_project, @model)
when Group
can?(current_user, :read_group, @model)
when Note when Note
can?(current_user, :read_project, @model.project) can?(current_user, :read_project, model.project)
else when User
# No authentication required for user avatars.
true true
else
permission = "read_#{model.class.to_s.underscore}".to_sym
can?(current_user, permission, model)
end end
return if authorized render_unauthorized unless authorized
end
def authorize_create_access!
# for now we support only personal snippets comments
authorized = can?(current_user, :comment_personal_snippet, model)
render_unauthorized unless authorized
end
def render_unauthorized
if current_user if current_user
render_404 render_404
else else
...@@ -58,17 +51,44 @@ class UploadsController < ApplicationController ...@@ -58,17 +51,44 @@ class UploadsController < ApplicationController
"project" => Project, "project" => Project,
"note" => Note, "note" => Note,
"group" => Group, "group" => Group,
"appearance" => Appearance "appearance" => Appearance,
"personal_snippet" => PersonalSnippet
} }
upload_models[params[:model]] upload_models[params[:model]]
end end
def upload_mount def upload_mount
return true unless params[:mounted_as]
upload_mounts = %w(avatar attachment file logo header_logo) upload_mounts = %w(avatar attachment file logo header_logo)
if upload_mounts.include?(params[:mounted_as]) if upload_mounts.include?(params[:mounted_as])
params[:mounted_as] params[:mounted_as]
end end
end end
def uploader
return @uploader if defined?(@uploader)
if model.is_a?(PersonalSnippet)
@uploader = PersonalFileUploader.new(model, params[:secret])
@uploader.retrieve_from_store!(params[:filename])
else
@uploader = @model.send(upload_mount)
redirect_to @uploader.url unless @uploader.file_storage?
end
@uploader
end
def uploader_class
PersonalFileUploader
end
def model
@model ||= find_model
end
end end
...@@ -119,7 +119,9 @@ module BlobHelper ...@@ -119,7 +119,9 @@ module BlobHelper
end end
def blob_raw_url def blob_raw_url
if @snippet if @build && @entry
raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path)
elsif @snippet
if @snippet.project_id if @snippet.project_id
raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) raw_namespace_project_snippet_path(@project.namespace, @project, @snippet)
else else
...@@ -250,6 +252,8 @@ module BlobHelper ...@@ -250,6 +252,8 @@ module BlobHelper
case viewer.blob.external_storage case viewer.blob.external_storage
when :lfs when :lfs
'it is stored in LFS' 'it is stored in LFS'
when :build_artifact
'it is stored as a job artifact'
else else
'it is stored externally' 'it is stored externally'
end end
......
...@@ -208,6 +208,8 @@ module GitlabRoutingHelper ...@@ -208,6 +208,8 @@ module GitlabRoutingHelper
browse_namespace_project_build_artifacts_path(*args) browse_namespace_project_build_artifacts_path(*args)
when 'file' when 'file'
file_namespace_project_build_artifacts_path(*args) file_namespace_project_build_artifacts_path(*args)
when 'raw'
raw_namespace_project_build_artifacts_path(*args)
end end
end end
......
...@@ -70,6 +70,14 @@ module SortingHelper ...@@ -70,6 +70,14 @@ module SortingHelper
} }
end end
def tags_sort_options_hash
{
sort_value_name => sort_title_name,
sort_value_recently_updated => sort_title_recently_updated,
sort_value_oldest_updated => sort_title_oldest_updated
}
end
def sort_title_priority def sort_title_priority
'Priority' 'Priority'
end end
......
module Ci
class ArtifactBlob
include BlobLike
attr_reader :entry
def initialize(entry)
@entry = entry
end
delegate :name, :path, to: :entry
def id
Digest::SHA1.hexdigest(path)
end
def size
entry.metadata[:size]
end
def data
"Build artifact #{path}"
end
def mode
entry.metadata[:mode]
end
def external_storage
:build_artifact
end
alias_method :external_size, :size
end
end
...@@ -30,6 +30,7 @@ class Event < ActiveRecord::Base ...@@ -30,6 +30,7 @@ class Event < ActiveRecord::Base
# Callbacks # Callbacks
after_create :reset_project_activity after_create :reset_project_activity
after_create :set_last_repository_updated_at, if: :push?
# Scopes # Scopes
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
...@@ -357,4 +358,9 @@ class Event < ActiveRecord::Base ...@@ -357,4 +358,9 @@ class Event < ActiveRecord::Base
def recent_update? def recent_update?
project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago
end end
def set_last_repository_updated_at
Project.unscoped.where(id: project_id).
update_all(last_repository_updated_at: created_at)
end
end end
...@@ -53,6 +53,11 @@ class Project < ActiveRecord::Base ...@@ -53,6 +53,11 @@ class Project < ActiveRecord::Base
update_column(:last_activity_at, self.created_at) update_column(:last_activity_at, self.created_at)
end end
after_create :set_last_repository_updated_at
def set_last_repository_updated_at
update_column(:last_repository_updated_at, self.created_at)
end
after_destroy :remove_pages after_destroy :remove_pages
# update visibility_level of forks # update visibility_level of forks
......
...@@ -183,6 +183,6 @@ class ProjectWiki ...@@ -183,6 +183,6 @@ class ProjectWiki
end end
def update_project_activity def update_project_activity
@project.touch(:last_activity_at) @project.touch(:last_activity_at, :last_repository_updated_at)
end end
end end
...@@ -3,11 +3,16 @@ class PersonalSnippetPolicy < BasePolicy ...@@ -3,11 +3,16 @@ class PersonalSnippetPolicy < BasePolicy
can! :read_personal_snippet if @subject.public? can! :read_personal_snippet if @subject.public?
return unless @user return unless @user
if @subject.public?
can! :comment_personal_snippet
end
if @subject.author == @user if @subject.author == @user
can! :read_personal_snippet can! :read_personal_snippet
can! :update_personal_snippet can! :update_personal_snippet
can! :destroy_personal_snippet can! :destroy_personal_snippet
can! :admin_personal_snippet can! :admin_personal_snippet
can! :comment_personal_snippet
end end
unless @user.external? unless @user.external?
...@@ -16,6 +21,7 @@ class PersonalSnippetPolicy < BasePolicy ...@@ -16,6 +21,7 @@ class PersonalSnippetPolicy < BasePolicy
if @subject.internal? && !@user.external? if @subject.internal? && !@user.external?
can! :read_personal_snippet can! :read_personal_snippet
can! :comment_personal_snippet
end end
end end
end end
...@@ -48,6 +48,17 @@ module Projects ...@@ -48,6 +48,17 @@ module Projects
available_public_keys.any? available_public_keys.any?
end end
def as_json
serializer = DeployKeySerializer.new
opts = { user: current_user }
{
enabled_keys: serializer.represent(enabled_keys, opts),
available_project_keys: serializer.represent(available_project_keys, opts),
public_keys: serializer.represent(available_public_keys, opts)
}
end
def to_partial_path def to_partial_path
'projects/deploy_keys/index' 'projects/deploy_keys/index'
end end
......
# Serializers
This is a documentation for classes located in `app/serializers` directory.
In GitLab, we use [grape-entities][grape-entity-project], accompanied by a
serializer, to convert a Ruby object to its JSON representation.
Serializers are typically used in controllers to build a JSON response
that is usually consumed by a frontend code.
## Why using a serializer is important?
Using serializers, instead of `to_json` method, has several benefits:
* it helps to prevent exposure of a sensitive data stored in the database
* it makes it easier to test what should and should not be exposed
* it makes it easier to reuse serialization entities that are building blocks
* it makes it easier to move complexity from controllers to easily testable
classes
* it encourages hiding complexity behind intentions-revealing interfaces
* it makes it easier to take care about serialization performance concerns
* it makes it easier to reduce merge conflicts between CE -> EE
* it makes it easier to benefit from domain driven development techniques
## What is a serializer?
A serializer is a class that encapsulates all business rules for building a
JSON response using serialization entities.
It is designed to be testable and to support passing additional context from
the controller.
## What is a serialization entity?
Entities are lightweight structures that allow to represent domain models
in a consistent and abstracted way, and reuse them as building blocks to
create a payload.
Entities located in `app/serializers` are usually derived from a
[`Grape::Entity`][grape-entity-class] class.
Serialization entities that do require to have a knowledge about specific
elements of the request, need to mix `RequestAwareEntity` in.
A serialization entity usually maps a domain model class into its JSON
representation. It rarely happens that a serialization entity exists without
a corresponding domain model class. As an example, we have an `Issue` class and
a corresponding `IssueSerializer`.
Serialization entites are designed to reuse other serialization entities, which
is a convenient way to create a multi-level JSON representation of a piece of
a domain model you want to serialize.
See [documentation for Grape Entites][grape-entity-readme] for more details.
## How to implement a serializer?
### Base implementation
In order to effectively implement a serializer it is necessary to create a new
class in `app/serializers`. See existing serializers as an example.
A new serializer should inherit from a `BaseSerializer` class. It is necessary
to specify which serialization entity will be used to serialize a resource.
```ruby
class MyResourceSerializer < BaseSerialize
entity MyResourceEntity
end
```
The example above shows how a most simple serializer can look like.
Given that the entity `MyResourceEntity` exists, you can now use
`MyResourceSerializer` in the controller by creating an instance of it, and
calling `MyResourceSerializer#represent(resource)` method.
Note that a `resource` can be either a single object, an array of objects or an
`ActiveRecord::Relation` object. A serialization entity should be smart enough
to accurately represent each of these.
It should not be necessary to use `Enumerable#map`, and it should be avoided
from the performance reasons.
### Choosing what gets serialized
It often happens that you might want to use the same serializer in many places,
but sometimes the intention is to only expose a small subset of object's
attributes in one place, and a different subset in another.
`BaseSerializer#represent(resource, opts = {})` method can take an additional
hash argument, `opts`, that defines what is going to be serialized.
`BaseSerializer` will pass these options to a serialization entity. See
how it is [documented in the upstream project][grape-entity-only].
With this approach you can extend the serializer to respond to methods that will
create a JSON response according to your needs.
```ruby
class PipelineSerializer < BaseSerializer
entity PipelineEntity
def represent_details(resource)
represent(resource, only: [:details])
end
def represent_status(resource)
represent(resource, only: [:status])
end
end
```
It is possible to use `only` and `except` keywords. Both keywords do support
nested attributes, like `except: [:id, { user: [:id] }]`.
Passing `only` and `except` to the `represent` method from a controller is
possible, but it defies principles of encapsulation and testability, and it is
better to avoid it, and to add a specific method to the serializer instead.
### Reusing serialization entities from the API
Public API in GitLab is implemented using [Grape][grape-project].
Under the hood it also uses [`Grape::Entity`][grape-entity-class] classes.
This means that it is possible to reuse these classes to implement internal
serializers.
You can either use such entity directly:
```ruby
class MyResourceSerializer < BaseSerializer
entity API::Entities::SomeEntity
end
```
Or derive a new serialization entity class from it:
```ruby
class MyEntity < API::Entities::SomeEntity
include RequestAwareEntity
unexpose :something
end
```
It might be a good idea to write specs for entities that do inherit from
the API, because when API payloads are changed / extended, it is easy to forget
about the impact on the internal API through a serializer that reuses API
entities.
It is usually safe to do that, because API entities rarely break backward
compatibility, but additional exposure may have a performance impact when API
gets extended significantly. Write tests that check if only necessary data is
exposed.
## How to write tests for a serializer?
Like every other class in the project, creating a serializer warrants writing
tests for it.
It is usually a good idea to test each public method in the serializer against
a valid payload. `BaseSerializer#represent` returns a hash, so it is possible
to use usual RSpec matchers like `include`.
Sometimes, when the payload is large, it makes sense to validate it entirely
using `match_response_schema` matcher along with a new fixture that can be
stored in `spec/fixtures/api/schemas/`. This matcher is using a `json-schema`
gem, which is quite flexible, see a [documentation][json-schema-gem] for it.
## How to use a serializer in a controller?
Once a new serializer is implemented, it is possible to use it in a controller.
Create an instance of the serializer and render the response.
```ruby
def index
format.json do
render json: MyResourceSerializer
.new(current_user: @current_user)
.represent_details(@project.resources)
nd
end
```
If it is necessary to include additional information in the payload, it is
possible to extend what is going to be rendered, the usual way:
```ruby
def index
format.json do
render json: {
resources: MyResourceSerializer
.new(current_user: @current_user)
.represent_details(@project.resources),
count: @project.resources.count
}
nd
end
```
Note that in these examples an additional context is being passed to the
serializer (`current_user: @current_user`).
## How to pass an additional context from the controller?
It is possible to pass an additional context from a controller to a
serializer and each serialization entity that is used in the process.
Serialization entities that do require an additional context have
`RequestAwareEntity` concern mixed in. This piece of the code exposes a method
called `request` in every serialization entity that is instantiated during
serialization.
An object returned by this method is an instance of `EntityRequest`, which
behaves like an `OpenStruct` object, with the difference that it will raise
an error if an unknown method is called.
In other words, in the previous example, `request` method will return an
instance of `EntityRequest` that responds to `current_user` method. It will be
available in every serialization entity instantiated by `MyResourceSerializer`.
`EntityRequest` is a workaround for [#20045][issue-20045] and is meant to be
refactored soon. Please avoid passing an additional context that is not
required by a serialization entity.
At the moment, the context that is passed to entities most often is
`current_user` and `project`.
## How is this related to using presenters?
Payload created by a serializer is usually a representation of the backed code,
combined with the current request data. Therefore, technically, serializers
are presenters that create payload consumed by a frontend code, usually Vue
components.
In GitLab, it is possible to use [presenters][presenters-readme], but
`BaseSerializer` still needs to learn how to use it, see [#30898][issue-30898].
It is possible to use presenters when serializer is used to represent only
a single object. It is not supported when `ActiveRecord::Relation` is being
serialized.
```ruby
MyObjectSerializer.new.represent(object.present)
```
## Best practices
1. Do not invoke a serializer from within a serialization entity.
If you need to use a serializer from within a serialization entity, it is
possible that you are missing a class for an important domain concept.
Consider creating a new domain class and a corresponding serialization
entity for it.
1. Use only one approach to switch behavior of the serializer.
It is possible to use a few approaches to switch a behavior of the
serializer. Most common are using a [Fluent Interface][fluent-interface]
and creating a separate `represent_something` methods.
Whatever you choose, it might be better to use only one approach at a time.
1. Do not forget about creating specs for serialization entities.
Writing tests for the serializer indeed does cover testing a behavior of
serialization entities that the serializer instantiates. However it might
be a good idea to write separate tests for entities as well, because these
are meant to be reused in different serializers, and a serializer can
change a behavior of a serialization entity.
1. Use `ActiveRecord::Relation` where possible
Using an `ActiveRecord::Relation` might help from the performance perspective.
1. Be diligent about passing an additional context from the controller.
Using `EntityRequest` and `RequestAwareEntity` is a workaround for the lack
of high-level mechanism. It is meant to be refactored, and current
implementation is error prone. Imagine the situation that one serialization
entity requires `request.user` attribute, but the second one wants
`request.current_user`. When it happens that these two entities are used in
the same serialization request, you might need to pass both parameters to
the serializer, which is obviously not a perfect situation.
When in doubt, pass only `current_user` and `project` if these are required.
1. Keep performance concerns in mind
Using a serializer incorrectly can have significant impact on the
performance.
Because serializers are technically presenters, it is often necessary
to calculate, for example, paths to various controller-actions.
Since using URL helpers usually involve passing `project` and `namespace`
adding `includes(project: :namespace)` in the serializer, can help to avoid
N+1 queries.
Also, try to avoid using `Enumerable#map` or other methods that will
execute a database query eagerly.
1. Avoid passing `only` and `except` from the controller.
1. Write tests checking for N+1 queries.
1. Write controller tests for actions / formats using serializers.
1. Write tests that check if only necessary data is exposed.
1. Write tests that check if no sensitive data is exposed.
## Future
* [Next iteration of serializers][issue-27569]
[grape-project]: http://www.ruby-grape.org
[grape-entity-project]: https://github.com/ruby-grape/grape-entity
[grape-entity-readme]: https://github.com/ruby-grape/grape-entity/blob/master/README.md
[grape-entity-class]: https://github.com/ruby-grape/grape-entity/blob/master/lib/grape_entity/entity.rb
[grape-entity-only]: https://github.com/ruby-grape/grape-entity/blob/master/README.md#returning-only-the-fields-you-want
[presenters-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/presenters/README.md
[fluent-interface]: https://en.wikipedia.org/wiki/Fluent_interface
[json-schema-gem]: https://github.com/ruby-json-schema/json-schema
[issue-20045]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20045
[issue-30898]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30898
[issue-27569]: https://gitlab.com/gitlab-org/gitlab-ce/issues/27569
class DeployKeyEntity < Grape::Entity
expose :id
expose :user_id
expose :title
expose :fingerprint
expose :can_push
expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned
expose :almost_orphaned?, as: :almost_orphaned
expose :created_at
expose :updated_at
expose :projects, using: ProjectEntity do |deploy_key|
deploy_key.projects.select { |project| options[:user].can?(:read_project, project) }
end
end
class DeployKeySerializer < BaseSerializer
entity DeployKeyEntity
end
class ProjectEntity < Grape::Entity
include RequestAwareEntity
expose :id
expose :name
expose :full_path do |project|
namespace_project_path(project.namespace, project)
end
expose :full_name do |project|
project.full_name
end
end
module Projects
class UploadService < BaseService
def initialize(project, file)
@project, @file = project, file
end
def execute
return nil unless @file && @file.size <= max_attachment_size
uploader = FileUploader.new(@project)
uploader.store!(@file)
uploader.to_h
end
private
def max_attachment_size
current_application_settings.max_attachment_size.megabytes.to_i
end
end
end
class UploadService
def initialize(model, file, uploader_class = FileUploader)
@model, @file, @uploader_class = model, file, uploader_class
end
def execute
return nil unless @file && @file.size <= max_attachment_size
uploader = @uploader_class.new(@model)
uploader.store!(@file)
uploader.to_h
end
private
def max_attachment_size
current_application_settings.max_attachment_size.megabytes.to_i
end
end
...@@ -30,8 +30,4 @@ class ArtifactUploader < GitlabUploader ...@@ -30,8 +30,4 @@ class ArtifactUploader < GitlabUploader
def filename def filename
file.try(:filename) file.try(:filename)
end end
def exists?
file.try(:exists?)
end
end end
...@@ -26,11 +26,11 @@ class FileUploader < GitlabUploader ...@@ -26,11 +26,11 @@ class FileUploader < GitlabUploader
File.join(CarrierWave.root, base_dir, model.path_with_namespace) File.join(CarrierWave.root, base_dir, model.path_with_namespace)
end end
attr_accessor :project attr_accessor :model
attr_reader :secret attr_reader :secret
def initialize(project, secret = nil) def initialize(model, secret = nil)
@project = project @model = model
@secret = secret || generate_secret @secret = secret || generate_secret
end end
...@@ -38,10 +38,6 @@ class FileUploader < GitlabUploader ...@@ -38,10 +38,6 @@ class FileUploader < GitlabUploader
File.join(dynamic_path_segment, @secret) File.join(dynamic_path_segment, @secret)
end end
def model
project
end
def relative_path def relative_path
self.file.path.sub("#{dynamic_path_segment}/", '') self.file.path.sub("#{dynamic_path_segment}/", '')
end end
......
...@@ -33,4 +33,8 @@ class GitlabUploader < CarrierWave::Uploader::Base ...@@ -33,4 +33,8 @@ class GitlabUploader < CarrierWave::Uploader::Base
def relative_path def relative_path
self.file.path.sub("#{root}/", '') self.file.path.sub("#{root}/", '')
end end
def exists?
file.try(:exists?)
end
end end
...@@ -9,10 +9,6 @@ class LfsObjectUploader < GitlabUploader ...@@ -9,10 +9,6 @@ class LfsObjectUploader < GitlabUploader
"#{Gitlab.config.lfs.storage_path}/tmp/cache" "#{Gitlab.config.lfs.storage_path}/tmp/cache"
end end
def exists?
file.try(:exists?)
end
def filename def filename
model.oid[4..-1] model.oid[4..-1]
end end
......
class PersonalFileUploader < FileUploader
def self.dynamic_path_segment(model)
File.join(CarrierWave.root, model_path(model))
end
private
def secure_url
File.join(self.class.model_path(model), secret, file.filename)
end
def self.model_path(model)
File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s)
end
end
...@@ -115,13 +115,20 @@ class DynamicPathValidator < ActiveModel::EachValidator ...@@ -115,13 +115,20 @@ class DynamicPathValidator < ActiveModel::EachValidator
# this would map to the activity-page of it's parent. # this would map to the activity-page of it's parent.
GROUP_ROUTES = %w[ GROUP_ROUTES = %w[
activity activity
analytics
audit_events
avatar avatar
edit edit
group_members group_members
hooks
issues issues
labels labels
ldap
ldap_group_links
merge_requests merge_requests
milestones milestones
notification_setting
pipeline_quota
projects projects
subgroups subgroups
].freeze ].freeze
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
- preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project) - preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project)
- if current_user - if current_user
:javascript :javascript
window.project_uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}";
window.preview_markdown_path = "#{preview_markdown_path}"; window.preview_markdown_path = "#{preview_markdown_path}";
- content_for :header_content do - content_for :header_content do
......
- path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) - path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path)
%tr.tree-item{ 'data-link' => path_to_file } %tr.tree-item{ 'data-link' => path_to_file }
- blob = file.blob
%td.tree-item-file-name %td.tree-item-file-name
= tree_icon('file', '664', file.name) = tree_icon('file', blob.mode, blob.name)
%span.str-truncated = link_to path_to_file do
= link_to file.name, path_to_file %span.str-truncated= blob.name
%td %td
= number_to_human_size(file.metadata[:size], precision: 2) = number_to_human_size(blob.size, precision: 2)
- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs'
= render "projects/pipelines/head"
= render "projects/builds/header", show_controls: false
#tree-holder.tree-holder
.nav-block
%ul.breadcrumb.repo-breadcrumb
%li
= link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build)
- path_breadcrumbs do |title, path|
- title = truncate(title, length: 40)
%li
- if path == @path
= link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do
%strong= title
- else
= link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path)
%article.file-holder
- blob = @entry.blob
.js-file-title.file-title-flex-parent
= render 'projects/blob/header_content', blob: blob
.file-actions.hidden-xs
= render 'projects/blob/viewer_switcher', blob: blob
.btn-group{ role: "group" }<
= copy_blob_source_button(blob)
= open_raw_blob_button(blob)
= render 'projects/blob/content', blob: blob
- page_title "New Branch" - page_title "New Branch"
- default_ref = params[:ref] || @project.default_branch
- if @error - if @error
.alert.alert-danger .alert.alert-danger
...@@ -16,12 +17,11 @@ ...@@ -16,12 +17,11 @@
.help-block.text-danger.js-branch-name-error .help-block.text-danger.js-branch-name-error
.form-group .form-group
= label_tag :ref, 'Create from', class: 'control-label' = label_tag :ref, 'Create from', class: 'control-label'
.col-sm-10 .col-sm-10.dropdown.create-from
= hidden_field_tag :ref, params[:ref] || @project.default_branch = hidden_field_tag :ref, default_ref
= dropdown_tag(params[:ref] || @project.default_branch, = button_tag type: 'button', title: default_ref, class: 'dropdown-toggle form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
options: { toggle_class: 'js-branch-select wide', .text-left.dropdown-toggle-text= default_ref
filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches", = render 'shared/ref_dropdown', dropdown_class: 'wide'
data: { selected: params[:ref] || @project.default_branch, field_name: 'ref' } })
.help-block Existing branch name, tag, or commit SHA .help-block Existing branch name, tag, or commit SHA
.form-actions .form-actions
= button_tag 'Create branch', class: 'btn btn-create', tabindex: 3 = button_tag 'Create branch', class: 'btn btn-create', tabindex: 3
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
= hidden_field_tag :from, params[:from] = hidden_field_tag :from, params[:from]
= button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do
.dropdown-toggle-text.str-truncated= params[:from] || 'Select branch/tag' .dropdown-toggle-text.str-truncated= params[:from] || 'Select branch/tag'
= render "ref_dropdown" = render 'shared/ref_dropdown'
.compare-ellipsis.inline ... .compare-ellipsis.inline ...
.form-group.dropdown.compare-form-group.to.js-compare-to-dropdown .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown
.input-group.inline-input-group .input-group.inline-input-group
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
= hidden_field_tag :to, params[:to] = hidden_field_tag :to, params[:to]
= button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do
.dropdown-toggle-text.str-truncated= params[:to] || 'Select branch/tag' .dropdown-toggle-text.str-truncated= params[:to] || 'Select branch/tag'
= render "ref_dropdown" = render 'shared/ref_dropdown'
&nbsp; &nbsp;
= button_tag "Compare", class: "btn btn-create commits-compare-btn" = button_tag "Compare", class: "btn btn-create commits-compare-btn"
- if @merge_request.present? - if @merge_request.present?
......
...@@ -10,25 +10,4 @@ ...@@ -10,25 +10,4 @@
= render @deploy_keys.form_partial_path = render @deploy_keys.form_partial_path
.col-lg-9.col-lg-offset-3 .col-lg-9.col-lg-offset-3
%hr %hr
.col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } }
%h5.prepend-top-0
Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size})
- if @deploy_keys.any_keys_enabled?
%ul.well-list
= render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key
- else
.settings-message.text-center
No deploy keys found. Create one with the form above.
%h5.prepend-top-default
Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size})
- if @deploy_keys.any_available_project_keys_enabled?
%ul.well-list
= render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key
- else
.settings-message.text-center
No deploy keys from your projects could be found. Create one with the form above or add existing one below.
- if @deploy_keys.any_available_public_keys_enabled?
%h5.prepend-top-default
Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size})
%ul.well-list
= render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key
...@@ -31,14 +31,14 @@ ...@@ -31,14 +31,14 @@
- if current_user - if current_user
- if note.emoji_awardable? - if note.emoji_awardable?
- user_authored = note.user_authored?(current_user) - user_authored = note.user_authored?(current_user)
= link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
= icon('spinner spin') = icon('spinner spin')
%span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
%span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
%span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
- if note_editable - if note_editable
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do
= icon('pencil', class: 'link-highlight') = icon('pencil', class: 'link-highlight')
= link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do
= icon('trash-o', class: 'danger-highlight') = icon('trash-o', class: 'danger-highlight')
- page_title "Repository" - page_title "Repository"
= render "projects/settings/head" = render "projects/settings/head"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('deploy_keys')
= render @deploy_keys = render @deploy_keys
= render "projects/protected_branches/index" = render "projects/protected_branches/index"
= render "projects/protected_tags/index" = render "projects/protected_tags/index"
- @no_container = true - @no_container = true
- @sort ||= sort_value_recently_updated
- page_title "Tags" - page_title "Tags"
= render "projects/commits/head" = render "projects/commits/head"
...@@ -14,16 +15,14 @@ ...@@ -14,16 +15,14 @@
.dropdown .dropdown
%button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} } %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} }
%span.light %span.light
= projects_sort_options_hash[@sort] = tags_sort_options_hash[@sort]
= icon('chevron-down') = icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
%li %li.dropdown-header
= link_to filter_tags_path(sort: sort_value_name) do Sort by
= sort_title_name - tags_sort_options_hash.each do |value, title|
= link_to filter_tags_path(sort: sort_value_recently_updated) do %li
= sort_title_recently_updated = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value)
= link_to filter_tags_path(sort: sort_value_oldest_updated) do
= sort_title_oldest_updated
- if can?(current_user, :push_code, @project) - if can?(current_user, :push_code, @project)
= link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do
New tag New tag
......
...@@ -11,8 +11,8 @@ ...@@ -11,8 +11,8 @@
= icon('caret-down') = icon('caret-down')
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
.arrow-up %li.js-builds-dropdown-list.scrollable-menu
.js-builds-dropdown-list.scrollable-menu
.js-builds-dropdown-loading.builds-dropdown-loading.hidden %li.js-builds-dropdown-loading.hidden
%span.fa.fa-spinner.fa-spin .text-center
%i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' }
.dropdown-menu.dropdown-menu-selectable - dropdown_class = local_assigns.fetch(:dropdown_class, '')
.dropdown-menu.dropdown-menu-selectable{ class: dropdown_class }
= dropdown_title "Select Git revision" = dropdown_title "Select Git revision"
= dropdown_filter "Filter by Git revision" = dropdown_filter "Filter by Git revision"
= dropdown_content = dropdown_content
......
...@@ -10,12 +10,16 @@ ...@@ -10,12 +10,16 @@
= form.label :source_branch, class: 'control-label' = form.label :source_branch, class: 'control-label'
.col-sm-10 .col-sm-10
.issuable-form-select-holder .issuable-form-select-holder
= form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 span2', disabled: true }) = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2', disabled: true })
.form-group .form-group
= form.label :target_branch, class: 'control-label' = form.label :target_branch, class: 'control-label'
.col-sm-10 .col-sm-10.target-branch-select-dropdown-container
.issuable-form-select-holder .issuable-form-select-holder
= form.select(:target_branch, issuable.target_branches, { include_blank: true }, { class: 'target_branch select2 span2', disabled: issuable.new_record?, data: { placeholder: "Select branch" }}) = form.select(:target_branch, issuable.target_branches,
{ include_blank: true },
{ class: 'target_branch js-target-branch-select',
disabled: issuable.new_record?,
data: { placeholder: "Select branch" }})
- if issuable.new_record? - if issuable.new_record?
&nbsp; &nbsp;
= link_to 'Change branches', mr_change_branches_path(issuable) = link_to 'Change branches', mr_change_branches_path(issuable)
- if current_user - if current_user
- if note.emoji_awardable? - if note.emoji_awardable?
- user_authored = note.user_authored?(current_user) - user_authored = note.user_authored?(current_user)
= link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do
= icon('spinner spin') = icon('spinner spin')
%span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
%span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
%span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
- if note_editable - if note_editable
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do
= icon('pencil', class: 'link-highlight') = icon('pencil', class: 'link-highlight')
= link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do
= icon('trash-o', class: 'danger-highlight') = icon('trash-o', class: 'danger-highlight')
---
title: Support uploaders for personal snippets comments
merge_request:
author:
---
title: Sort the network graph both by commit date and topographically
merge_request: 11057
author:
---
title: Fix environments vue architecture to match documentation
merge_request:
author:
---
title: Job dropdown of pipeline mini graph updates in realtime when its opened
merge_request:
author:
---
title: Add tooltips to note action buttons
merge_request:
author:
---
title: Deploy keys load are loaded async
merge_request:
author:
---
title: Add artifact file page that uses the blob viewer
merge_request:
author:
---
title: Fixed tags sort from defaulting to empty
merge_request:
author:
...@@ -183,6 +183,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -183,6 +183,7 @@ constraints(ProjectUrlConstrainer.new) do
get :download get :download
get :browse, path: 'browse(/*path)', format: false get :browse, path: 'browse(/*path)', format: false
get :file, path: 'file/*path', format: false get :file, path: 'file/*path', format: false
get :raw, path: 'raw/*path', format: false
post :keep post :keep
end end
end end
......
...@@ -4,6 +4,11 @@ scope path: :uploads do ...@@ -4,6 +4,11 @@ scope path: :uploads do
to: "uploads#show", to: "uploads#show",
constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ } constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ }
# show uploads for models, snippets (notes) available for now
get ':model/:id/:secret/:filename',
to: 'uploads#show',
constraints: { model: /personal_snippet/, id: /\d+/, filename: /[^\/]+/ }
# Appearance # Appearance
get ":model/:mounted_as/:id/:filename", get ":model/:mounted_as/:id/:filename",
to: "uploads#show", to: "uploads#show",
...@@ -13,6 +18,12 @@ scope path: :uploads do ...@@ -13,6 +18,12 @@ scope path: :uploads do
get ":namespace_id/:project_id/:secret/:filename", get ":namespace_id/:project_id/:secret/:filename",
to: "projects/uploads#show", to: "projects/uploads#show",
constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ } constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ }
# create uploads for models, snippets (notes) available for now
post ':model/:id/',
to: 'uploads#create',
constraints: { model: /personal_snippet/, id: /\d+/ },
as: 'upload'
end end
# Redirect old note attachments path to new uploads path. # Redirect old note attachments path to new uploads path.
......
...@@ -26,6 +26,7 @@ var config = { ...@@ -26,6 +26,7 @@ var config = {
common_d3: ['d3'], common_d3: ['d3'],
cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js',
commit_pipelines: './commit/pipelines/pipelines_bundle.js', commit_pipelines: './commit/pipelines/pipelines_bundle.js',
deploy_keys: './deploy_keys/index.js',
diff_notes: './diff_notes/diff_notes_bundle.js', diff_notes: './diff_notes/diff_notes_bundle.js',
environments: './environments/environments_bundle.js', environments: './environments/environments_bundle.js',
environments_folder: './environments/folder/environments_folder_bundle.js', environments_folder: './environments/folder/environments_folder_bundle.js',
...@@ -123,6 +124,7 @@ var config = { ...@@ -123,6 +124,7 @@ var config = {
'boards', 'boards',
'commit_pipelines', 'commit_pipelines',
'cycle_analytics', 'cycle_analytics',
'deploy_keys',
'diff_notes', 'diff_notes',
'environments', 'environments',
'environments_folder', 'environments_folder',
......
class AddLastRepositoryUpdatedAtToProjects < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :projects, :last_repository_updated_at, :datetime
end
end
class AddIndexToLastRepositoryUpdatedAtOnProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index(:projects, :last_repository_updated_at)
end
def down
remove_concurrent_index(:projects, :last_repository_updated_at) if index_exists?(:projects, :last_repository_updated_at)
end
end
...@@ -36,10 +36,17 @@ class RenameReservedDynamicPaths < ActiveRecord::Migration ...@@ -36,10 +36,17 @@ class RenameReservedDynamicPaths < ActiveRecord::Migration
DISSALLOWED_GROUP_PATHS = %w[ DISSALLOWED_GROUP_PATHS = %w[
activity activity
analytics
audit_events
avatar avatar
group_members group_members
hooks
labels labels
ldap
ldap_group_links
milestones milestones
notification_setting
pipeline_quota
subgroups subgroups
] ]
......
...@@ -973,6 +973,7 @@ ActiveRecord::Schema.define(version: 20170504102911) do ...@@ -973,6 +973,7 @@ ActiveRecord::Schema.define(version: 20170504102911) do
t.boolean "printing_merge_request_link_enabled", default: true, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false
t.string "import_jid" t.string "import_jid"
t.integer "cached_markdown_version" t.integer "cached_markdown_version"
t.datetime "last_repository_updated_at"
end end
add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree
...@@ -981,6 +982,7 @@ ActiveRecord::Schema.define(version: 20170504102911) do ...@@ -981,6 +982,7 @@ ActiveRecord::Schema.define(version: 20170504102911) do
add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
add_index "projects", ["last_activity_at"], name: "index_projects_on_last_activity_at", using: :btree add_index "projects", ["last_activity_at"], name: "index_projects_on_last_activity_at", using: :btree
add_index "projects", ["last_repository_check_failed"], name: "index_projects_on_last_repository_check_failed", using: :btree add_index "projects", ["last_repository_check_failed"], name: "index_projects_on_last_repository_check_failed", using: :btree
add_index "projects", ["last_repository_updated_at"], name: "index_projects_on_last_repository_updated_at", using: :btree
add_index "projects", ["name"], name: "index_projects_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} add_index "projects", ["name"], name: "index_projects_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"}
add_index "projects", ["namespace_id"], name: "index_projects_on_namespace_id", using: :btree add_index "projects", ["namespace_id"], name: "index_projects_on_namespace_id", using: :btree
add_index "projects", ["path"], name: "index_projects_on_path", using: :btree add_index "projects", ["path"], name: "index_projects_on_path", using: :btree
...@@ -1412,4 +1414,4 @@ ActiveRecord::Schema.define(version: 20170504102911) do ...@@ -1412,4 +1414,4 @@ ActiveRecord::Schema.define(version: 20170504102911) do
add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade
add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade
add_foreign_key "u2f_registrations", "users" add_foreign_key "u2f_registrations", "users"
end end
\ No newline at end of file
# Code Review Guidelines # Code Review Guidelines
## Getting your merge request reviewed, approved, and merged
There are a few rules to get your merge request accepted:
1. Your merge request should only be **merged by a [maintainer][team]**.
1. If your merge request includes only backend changes [^1], it must be
**approved by a [backend maintainer][team]**.
1. If your merge request includes only frontend changes [^1], it must be
**approved by a [frontend maintainer][team]**.
1. If your merge request includes frontend and backend changes [^1], it must
be **approved by a [frontend and a backend maintainer][team]**.
1. To lower the amount of merge requests maintainers need to review, you can
ask or assign any [reviewers][team] for a first review.
1. If you need some guidance (e.g. it's your first merge request), feel free
to ask one of the [Merge request coaches][team].
1. The reviewer will assign the merge request to a maintainer once the
reviewer is satisfied with the state of the merge request.
## Best practices
This guide contains advice and best practices for performing code review, and This guide contains advice and best practices for performing code review, and
having your code reviewed. having your code reviewed.
...@@ -12,7 +32,7 @@ of colleagues and contributors. However, the final decision to accept a merge ...@@ -12,7 +32,7 @@ of colleagues and contributors. However, the final decision to accept a merge
request is up to one the project's maintainers, denoted on the request is up to one the project's maintainers, denoted on the
[team page](https://about.gitlab.com/team). [team page](https://about.gitlab.com/team).
## Everyone ### Everyone
- Accept that many programming decisions are opinions. Discuss tradeoffs, which - Accept that many programming decisions are opinions. Discuss tradeoffs, which
you prefer, and reach a resolution quickly. you prefer, and reach a resolution quickly.
...@@ -31,8 +51,11 @@ request is up to one the project's maintainers, denoted on the ...@@ -31,8 +51,11 @@ request is up to one the project's maintainers, denoted on the
- Consider one-on-one chats or video calls if there are too many "I didn't - Consider one-on-one chats or video calls if there are too many "I didn't
understand" or "Alternative solution:" comments. Post a follow-up comment understand" or "Alternative solution:" comments. Post a follow-up comment
summarizing one-on-one discussion. summarizing one-on-one discussion.
- If you ask a question to a specific person, always start the comment by
mentioning them; this will ensure they see it if their notification level is
set to "mentioned" and other people will understand they don't have to respond.
## Having your code reviewed ### Having your code reviewed
Please keep in mind that code review is a process that can take multiple Please keep in mind that code review is a process that can take multiple
iterations, and reviewers may spot things later that they may not have seen the iterations, and reviewers may spot things later that they may not have seen the
...@@ -50,11 +73,12 @@ first time. ...@@ -50,11 +73,12 @@ first time.
- Extract unrelated changes and refactorings into future merge requests/issues. - Extract unrelated changes and refactorings into future merge requests/issues.
- Seek to understand the reviewer's perspective. - Seek to understand the reviewer's perspective.
- Try to respond to every comment. - Try to respond to every comment.
- Let the reviewer select the "Resolve discussion" buttons.
- Push commits based on earlier rounds of feedback as isolated commits to the - Push commits based on earlier rounds of feedback as isolated commits to the
branch. Do not squash until the branch is ready to merge. Reviewers should be branch. Do not squash until the branch is ready to merge. Reviewers should be
able to read individual updates based on their earlier feedback. able to read individual updates based on their earlier feedback.
## Reviewing code ### Reviewing code
Understand why the change is necessary (fixes a bug, improves the user Understand why the change is necessary (fixes a bug, improves the user
experience, refactors the existing code). Then: experience, refactors the existing code). Then:
...@@ -69,12 +93,19 @@ experience, refactors the existing code). Then: ...@@ -69,12 +93,19 @@ experience, refactors the existing code). Then:
someone else would be confused by it as well. someone else would be confused by it as well.
- After a round of line notes, it can be helpful to post a summary note such as - After a round of line notes, it can be helpful to post a summary note such as
"LGTM :thumbsup:", or "Just a couple things to address." "LGTM :thumbsup:", or "Just a couple things to address."
- Assign the merge request to the author if changes are required following your
review.
- Set the milestone before merging a merge request.
- Avoid accepting a merge request before the job succeeds. Of course, "Merge - Avoid accepting a merge request before the job succeeds. Of course, "Merge
When Pipeline Succeeds" (MWPS) is fine. When Pipeline Succeeds" (MWPS) is fine.
- If you set the MR to "Merge When Pipeline Succeeds", you should take over - If you set the MR to "Merge When Pipeline Succeeds", you should take over
subsequent revisions for anything that would be spotted after that. subsequent revisions for anything that would be spotted after that.
- Consider using the [Squash and
merge][squash-and-merge] feature when the merge request has a lot of commits.
[squash-and-merge]: https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#squash-and-merge
## The right balance ### The right balance
One of the most difficult things during code review is finding the right One of the most difficult things during code review is finding the right
balance in how deep the reviewer can interfere with the code created by a balance in how deep the reviewer can interfere with the code created by a
...@@ -100,7 +131,7 @@ reviewee. ...@@ -100,7 +131,7 @@ reviewee.
tomorrow. When you are not able to find the right balance, ask other people tomorrow. When you are not able to find the right balance, ask other people
about their opinion. about their opinion.
## Credits ### Credits
Largely based on the [thoughtbot code review guide]. Largely based on the [thoughtbot code review guide].
......
...@@ -104,6 +104,7 @@ cd /home/git/gitlab-shell ...@@ -104,6 +104,7 @@ cd /home/git/gitlab-shell
sudo -u git -H git fetch --all --tags sudo -u git -H git fetch --all --tags
sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION)
sudo -u git -H bin/compile
``` ```
### 7. Update gitlab-workhorse ### 7. Update gitlab-workhorse
......
...@@ -75,6 +75,7 @@ cd /home/git/gitlab-shell ...@@ -75,6 +75,7 @@ cd /home/git/gitlab-shell
sudo -u git -H git fetch --all --tags sudo -u git -H git fetch --all --tags
sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` -b v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` -b v`cat /home/git/gitlab/GITLAB_SHELL_VERSION`
sudo -u git -H sh -c 'if [ -x bin/compile ]; then bin/compile; fi'
``` ```
### 6. Start application ### 6. Start application
......
...@@ -60,6 +60,7 @@ GitLab Shell might be outdated, running the commands below ensures you're using ...@@ -60,6 +60,7 @@ GitLab Shell might be outdated, running the commands below ensures you're using
cd /home/git/gitlab-shell cd /home/git/gitlab-shell
sudo -u git -H git fetch sudo -u git -H git fetch
sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION` sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION`
sudo -u git -H sh -c 'if [ -x bin/compile ] ; then bin/compile ; fi'
``` ```
## One line upgrade command ## One line upgrade command
...@@ -78,6 +79,7 @@ cd /home/git/gitlab; \ ...@@ -78,6 +79,7 @@ cd /home/git/gitlab; \
cd /home/git/gitlab-shell; \ cd /home/git/gitlab-shell; \
sudo -u git -H git fetch; \ sudo -u git -H git fetch; \
sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION`; \ sudo -u git -H git checkout v`cat /home/git/gitlab/GITLAB_SHELL_VERSION`; \
sudo -u git -H sh -c 'if [ -x bin/compile ] ; then bin/compile ; fi'; \
cd /home/git/gitlab; \ cd /home/git/gitlab; \
sudo service gitlab start; \ sudo service gitlab start; \
sudo service nginx restart; \ sudo service nginx restart; \
......
...@@ -46,13 +46,14 @@ Feature: Project Builds Artifacts ...@@ -46,13 +46,14 @@ Feature: Project Builds Artifacts
And I navigate to parent directory of directory with invalid name And I navigate to parent directory of directory with invalid name
Then I should not see directory with invalid name on the list Then I should not see directory with invalid name on the list
@javascript
Scenario: I download a single file from build artifacts Scenario: I download a single file from build artifacts
Given recent build has artifacts available Given recent build has artifacts available
And recent build has artifacts metadata available And recent build has artifacts metadata available
When I visit recent build details page When I visit recent build details page
And I click artifacts browse button And I click artifacts browse button
And I click a link to file within build artifacts And I click a link to file within build artifacts
Then download of a file extracted from build artifacts should start Then I see a download link
@javascript @javascript
Scenario: I click on a row in an artifacts table Scenario: I click on a row in an artifacts table
......
...@@ -3,28 +3,33 @@ Feature: Project Deploy Keys ...@@ -3,28 +3,33 @@ Feature: Project Deploy Keys
Given I sign in as a user Given I sign in as a user
And I own project "Shop" And I own project "Shop"
@javascript
Scenario: I should see deploy keys list Scenario: I should see deploy keys list
Given project has deploy key Given project has deploy key
When I visit project deploy keys page When I visit project deploy keys page
Then I should see project deploy key Then I should see project deploy key
@javascript
Scenario: I should see project deploy keys Scenario: I should see project deploy keys
Given other projects have deploy keys Given other projects have deploy keys
When I visit project deploy keys page When I visit project deploy keys page
Then I should see other project deploy key Then I should see other project deploy key
And I should only see the same deploy key once And I should only see the same deploy key once
@javascript
Scenario: I should see public deploy keys Scenario: I should see public deploy keys
Given public deploy key exists Given public deploy key exists
When I visit project deploy keys page When I visit project deploy keys page
Then I should see public deploy key Then I should see public deploy key
@javascript
Scenario: I add new deploy key Scenario: I add new deploy key
Given I visit project deploy keys page Given I visit project deploy keys page
And I submit new deploy key And I submit new deploy key
Then I should be on deploy keys page Then I should be on deploy keys page
And I should see newly created deploy key And I should see newly created deploy key
@javascript
Scenario: I attach other project deploy key to project Scenario: I attach other project deploy key to project
Given other projects have deploy keys Given other projects have deploy keys
And I visit project deploy keys page And I visit project deploy keys page
...@@ -32,6 +37,7 @@ Feature: Project Deploy Keys ...@@ -32,6 +37,7 @@ Feature: Project Deploy Keys
Then I should be on deploy keys page Then I should be on deploy keys page
And I should see newly created deploy key And I should see newly created deploy key
@javascript
Scenario: I attach public deploy key to project Scenario: I attach public deploy key to project
Given public deploy key exists Given public deploy key exists
And I visit project deploy keys page And I visit project deploy keys page
......
...@@ -3,6 +3,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps ...@@ -3,6 +3,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps
include SharedProject include SharedProject
include SharedBuilds include SharedBuilds
include RepoHelpers include RepoHelpers
include WaitForAjax
step 'I click artifacts download button' do step 'I click artifacts download button' do
click_link 'Download' click_link 'Download'
...@@ -78,19 +79,11 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps ...@@ -78,19 +79,11 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps
step 'I click a link to file within build artifacts' do step 'I click a link to file within build artifacts' do
page.within('.tree-table') { find_link('ci_artifacts.txt').click } page.within('.tree-table') { find_link('ci_artifacts.txt').click }
wait_for_ajax
end end
step 'download of a file extracted from build artifacts should start' do step 'I see a download link' do
send_data = response_headers[Gitlab::Workhorse::SEND_DATA_HEADER] expect(page).to have_link 'download it'
expect(send_data).to start_with('artifacts-entry:')
base64_params = send_data.sub(/\Aartifacts\-entry:/, '')
params = JSON.parse(Base64.urlsafe_decode64(base64_params))
expect(params.keys).to eq(%w(Archive Entry))
expect(params['Archive']).to end_with('build_artifacts.zip')
expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt'))
end end
step 'I click a first row within build artifacts table' do step 'I click a first row within build artifacts table' do
......
...@@ -8,19 +8,19 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps ...@@ -8,19 +8,19 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end end
step 'I should see project deploy key' do step 'I should see project deploy key' do
page.within '.deploy-keys' do page.within(find('.deploy-keys')) do
expect(page).to have_content deploy_key.title expect(page).to have_content deploy_key.title
end end
end end
step 'I should see other project deploy key' do step 'I should see other project deploy key' do
page.within '.deploy-keys' do page.within(find('.deploy-keys')) do
expect(page).to have_content other_deploy_key.title expect(page).to have_content other_deploy_key.title
end end
end end
step 'I should see public deploy key' do step 'I should see public deploy key' do
page.within '.deploy-keys' do page.within(find('.deploy-keys')) do
expect(page).to have_content public_deploy_key.title expect(page).to have_content public_deploy_key.title
end end
end end
...@@ -40,7 +40,8 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps ...@@ -40,7 +40,8 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end end
step 'I should see newly created deploy key' do step 'I should see newly created deploy key' do
page.within '.deploy-keys' do @project.reload
page.within(find('.deploy-keys')) do
expect(page).to have_content(deploy_key.title) expect(page).to have_content(deploy_key.title)
end end
end end
...@@ -56,7 +57,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps ...@@ -56,7 +57,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end end
step 'I should only see the same deploy key once' do step 'I should only see the same deploy key once' do
page.within '.deploy-keys' do page.within(find('.deploy-keys')) do
expect(page).to have_selector('ul li', count: 1) expect(page).to have_selector('ul li', count: 1)
end end
end end
...@@ -66,8 +67,9 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps ...@@ -66,8 +67,9 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end end
step 'I click attach deploy key' do step 'I click attach deploy key' do
page.within '.deploy-keys' do page.within(find('.deploy-keys')) do
click_link 'Enable' click_button 'Enable'
expect(page).not_to have_selector('.fa-spinner')
end end
end end
......
...@@ -385,7 +385,7 @@ module API ...@@ -385,7 +385,7 @@ module API
requires :file, type: File, desc: 'The file to be uploaded' requires :file, type: File, desc: 'The file to be uploaded'
end end
post ":id/uploads" do post ":id/uploads" do
::Projects::UploadService.new(user_project, params[:file]).execute UploadService.new(user_project, params[:file]).execute
end end
desc 'Get the users list of a project' do desc 'Get the users list of a project' do
......
...@@ -452,7 +452,7 @@ module API ...@@ -452,7 +452,7 @@ module API
requires :file, type: File, desc: 'The file to be uploaded' requires :file, type: File, desc: 'The file to be uploaded'
end end
post ":id/uploads" do post ":id/uploads" do
::Projects::UploadService.new(user_project, params[:file]).execute UploadService.new(user_project, params[:file]).execute
end end
desc 'Get the users list of a project' do desc 'Get the users list of a project' do
......
...@@ -37,6 +37,12 @@ module Gitlab ...@@ -37,6 +37,12 @@ module Gitlab
!directory? !directory?
end end
def blob
return unless file?
@blob ||= Blob.decorate(::Ci::ArtifactBlob.new(self), nil)
end
def has_parent? def has_parent?
nodes > 0 nodes > 0
end end
......
...@@ -21,7 +21,7 @@ module Gitlab ...@@ -21,7 +21,7 @@ module Gitlab
content_type: attachment.content_type content_type: attachment.content_type
} }
link = ::Projects::UploadService.new(project, file).execute link = UploadService.new(project, file).execute
attachments << link if link attachments << link if link
ensure ensure
tmp.close! tmp.close!
......
...@@ -499,8 +499,9 @@ module Gitlab ...@@ -499,8 +499,9 @@ module Gitlab
# :contains is the commit contained by the refs from which to begin (SHA1 or name) # :contains is the commit contained by the refs from which to begin (SHA1 or name)
# :max_count is the maximum number of commits to fetch # :max_count is the maximum number of commits to fetch
# :skip is the number of commits to skip # :skip is the number of commits to skip
# :order is the commits order and allowed value is :none (default), :date, or :topo # :order is the commits order and allowed value is :none (default), :date,
# commit ordering types are documented here: # :topo, or any combination of them (in an array). Commit ordering types
# are documented here:
# http://www.rubydoc.info/github/libgit2/rugged/Rugged#SORT_NONE-constant) # http://www.rubydoc.info/github/libgit2/rugged/Rugged#SORT_NONE-constant)
# #
def find_commits(options = {}) def find_commits(options = {})
...@@ -1269,16 +1270,18 @@ module Gitlab ...@@ -1269,16 +1270,18 @@ module Gitlab
raise CommandError.new(e) raise CommandError.new(e)
end end
# Returns the `Rugged` sorting type constant for a given # Returns the `Rugged` sorting type constant for one or more given
# sort type key. Valid keys are `:none`, `:topo`, and `:date` # sort types. Valid keys are `:none`, `:topo`, and `:date`, or an array
def rugged_sort_type(key) # containing more than one of them. `:date` uses a combination of date and
# topological sorting to closer mimic git's native ordering.
def rugged_sort_type(sort_type)
@rugged_sort_types ||= { @rugged_sort_types ||= {
none: Rugged::SORT_NONE, none: Rugged::SORT_NONE,
topo: Rugged::SORT_TOPO, topo: Rugged::SORT_TOPO,
date: Rugged::SORT_DATE date: Rugged::SORT_DATE | Rugged::SORT_TOPO
} }
@rugged_sort_types.fetch(key, Rugged::SORT_NONE) @rugged_sort_types.fetch(sort_type, Rugged::SORT_NONE)
end end
end end
end end
......
...@@ -41,8 +41,14 @@ namespace :gitlab do ...@@ -41,8 +41,14 @@ namespace :gitlab do
# Generate config.yml based on existing gitlab settings # Generate config.yml based on existing gitlab settings
File.open("config.yml", "w+") {|f| f.puts config.to_yaml} File.open("config.yml", "w+") {|f| f.puts config.to_yaml}
# Launch installation process [
system(*%w(bin/install) + repository_storage_paths_args) %w(bin/install) + repository_storage_paths_args,
%w(bin/compile)
].each do |cmd|
unless Kernel.system(*cmd)
raise "command failed: #{cmd.join(' ')}"
end
end
end end
# (Re)create hooks # (Re)create hooks
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
require ::File.expand_path('../lib/gitlab/popen', __dir__) require ::File.expand_path('../lib/gitlab/popen', __dir__)
tasks = [ tasks = [
%w[bundle exec bundle-audit check --update --ignore CVE-2016-4658],
%w[bundle exec rake config_lint], %w[bundle exec rake config_lint],
%w[bundle exec rake flay], %w[bundle exec rake flay],
%w[bundle exec rake haml_lint], %w[bundle exec rake haml_lint],
......
...@@ -14,20 +14,91 @@ describe Projects::ArtifactsController do ...@@ -14,20 +14,91 @@ describe Projects::ArtifactsController do
let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
describe 'GET /:project/builds/artifacts/:ref_name/browse?job=name' do before do
before do project.team << [user, :developer]
project.team << [user, :developer]
login_as(user) sign_in(user)
end
describe 'GET download' do
it 'sends the artifacts file' do
expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original
get :download, namespace_id: project.namespace, project_id: project, build_id: build
end
end
describe 'GET browse' do
context 'when the directory exists' do
it 'renders the browse view' do
get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2'
expect(response).to render_template('projects/artifacts/browse')
end
end
context 'when the directory does not exist' do
it 'responds Not Found' do
get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown'
expect(response).to be_not_found
end
end
end
describe 'GET file' do
context 'when the file exists' do
it 'renders the file view' do
get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt'
expect(response).to render_template('projects/artifacts/file')
end
end end
def path_from_ref( context 'when the file does not exist' do
ref = pipeline.ref, job = build.name, path = 'browse') it 'responds Not Found' do
latest_succeeded_namespace_project_artifacts_path( get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown'
project.namespace,
project, expect(response).to be_not_found
[ref, path].join('/'), end
job: job) end
end
describe 'GET raw' do
context 'when the file exists' do
it 'serves the file using workhorse' do
get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt'
send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]
expect(send_data).to start_with('artifacts-entry:')
base64_params = send_data.sub(/\Aartifacts\-entry:/, '')
params = JSON.parse(Base64.urlsafe_decode64(base64_params))
expect(params.keys).to eq(%w(Archive Entry))
expect(params['Archive']).to end_with('build_artifacts.zip')
expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt'))
end
end
context 'when the file does not exist' do
it 'responds Not Found' do
get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown'
expect(response).to be_not_found
end
end
end
describe 'GET latest_succeeded' do
def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse')
{
namespace_id: project.namespace,
project_id: project,
ref_name_and_path: File.join(ref, path),
job: job
}
end end
context 'cannot find the build' do context 'cannot find the build' do
...@@ -37,7 +108,7 @@ describe Projects::ArtifactsController do ...@@ -37,7 +108,7 @@ describe Projects::ArtifactsController do
context 'has no such ref' do context 'has no such ref' do
before do before do
get path_from_ref('TAIL', build.name) get :latest_succeeded, params_from_ref('TAIL', build.name)
end end
it_behaves_like 'not found' it_behaves_like 'not found'
...@@ -45,7 +116,7 @@ describe Projects::ArtifactsController do ...@@ -45,7 +116,7 @@ describe Projects::ArtifactsController do
context 'has no such build' do context 'has no such build' do
before do before do
get path_from_ref(pipeline.ref, 'NOBUILD') get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD')
end end
it_behaves_like 'not found' it_behaves_like 'not found'
...@@ -53,7 +124,7 @@ describe Projects::ArtifactsController do ...@@ -53,7 +124,7 @@ describe Projects::ArtifactsController do
context 'has no path' do context 'has no path' do
before do before do
get path_from_ref(pipeline.sha, build.name, '') get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '')
end end
it_behaves_like 'not found' it_behaves_like 'not found'
...@@ -77,7 +148,7 @@ describe Projects::ArtifactsController do ...@@ -77,7 +148,7 @@ describe Projects::ArtifactsController do
pipeline.update(ref: 'master', pipeline.update(ref: 'master',
sha: project.commit('master').sha) sha: project.commit('master').sha)
get path_from_ref('master') get :latest_succeeded, params_from_ref('master')
end end
it_behaves_like 'redirect to the build' it_behaves_like 'redirect to the build'
...@@ -88,7 +159,7 @@ describe Projects::ArtifactsController do ...@@ -88,7 +159,7 @@ describe Projects::ArtifactsController do
pipeline.update(ref: 'improve/awesome', pipeline.update(ref: 'improve/awesome',
sha: project.commit('improve/awesome').sha) sha: project.commit('improve/awesome').sha)
get path_from_ref('improve/awesome') get :latest_succeeded, params_from_ref('improve/awesome')
end end
it_behaves_like 'redirect to the build' it_behaves_like 'redirect to the build'
...@@ -99,7 +170,7 @@ describe Projects::ArtifactsController do ...@@ -99,7 +170,7 @@ describe Projects::ArtifactsController do
pipeline.update(ref: 'improve/awesome', pipeline.update(ref: 'improve/awesome',
sha: project.commit('improve/awesome').sha) sha: project.commit('improve/awesome').sha)
get path_from_ref('improve/awesome', build.name, 'file/README.md') get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md')
end end
it 'redirects' do it 'redirects' do
......
require 'spec_helper'
describe Projects::DeployKeysController do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
before do
project.team << [user, :master]
sign_in(user)
end
describe 'GET index' do
let(:params) do
{ namespace_id: project.namespace, project_id: project }
end
context 'when html requested' do
it 'redirects to blob' do
get :index, params
expect(response).to redirect_to(namespace_project_settings_repository_path(params))
end
end
context 'when json requested' do
let(:project2) { create(:empty_project, :internal)}
let(:project_private) { create(:empty_project, :private)}
let(:deploy_key_internal) do
create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com')
end
let(:deploy_key_actual) do
create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com')
end
let!(:deploy_key_public) { create(:deploy_key, public: true) }
let!(:deploy_keys_project_internal) do
create(:deploy_keys_project, project: project2, deploy_key: deploy_key_internal)
end
let!(:deploy_keys_actual_project) do
create(:deploy_keys_project, project: project, deploy_key: deploy_key_actual)
end
let!(:deploy_keys_project_private) do
create(:deploy_keys_project, project: project_private, deploy_key: create(:another_deploy_key))
end
before do
project2.team << [user, :developer]
end
it 'returns json in a correct format' do
get :index, params.merge(format: :json)
json = JSON.parse(response.body)
expect(json.keys).to match_array(%w(enabled_keys available_project_keys public_keys))
expect(json['enabled_keys'].count).to eq(1)
expect(json['available_project_keys'].count).to eq(1)
expect(json['public_keys'].count).to eq(1)
end
end
end
end
...@@ -8,6 +8,93 @@ end ...@@ -8,6 +8,93 @@ end
describe UploadsController do describe UploadsController do
let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) }
describe 'POST create' do
let(:model) { 'personal_snippet' }
let(:snippet) { create(:personal_snippet, :public) }
let(:jpg) { fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg') }
let(:txt) { fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') }
context 'when a user does not have permissions to upload a file' do
it "returns 401 when the user is not logged in" do
post :create, model: model, id: snippet.id, format: :json
expect(response).to have_http_status(401)
end
it "returns 404 when user can't comment on a snippet" do
private_snippet = create(:personal_snippet, :private)
sign_in(user)
post :create, model: model, id: private_snippet.id, format: :json
expect(response).to have_http_status(404)
end
end
context 'when a user is logged in' do
before do
sign_in(user)
end
it "returns an error without file" do
post :create, model: model, id: snippet.id, format: :json
expect(response).to have_http_status(422)
end
it "returns an error with invalid model" do
expect { post :create, model: 'invalid', id: snippet.id, format: :json }
.to raise_error(ActionController::UrlGenerationError)
end
it "returns 404 status when object not found" do
post :create, model: model, id: 9999, format: :json
expect(response).to have_http_status(404)
end
context 'with valid image' do
before do
post :create, model: 'personal_snippet', id: snippet.id, file: jpg, format: :json
end
it 'returns a content with original filename, new link, and correct type.' do
expect(response.body).to match '\"alt\":\"rails_sample\"'
expect(response.body).to match "\"url\":\"/uploads"
end
it 'creates a corresponding Upload record' do
upload = Upload.last
aggregate_failures do
expect(upload).to exist
expect(upload.model).to eq snippet
end
end
end
context 'with valid non-image file' do
before do
post :create, model: 'personal_snippet', id: snippet.id, file: txt, format: :json
end
it 'returns a content with original filename, new link, and correct type.' do
expect(response.body).to match '\"alt\":\"doc_sample.txt\"'
expect(response.body).to match "\"url\":\"/uploads"
end
it 'creates a corresponding Upload record' do
upload = Upload.last
aggregate_failures do
expect(upload).to exist
expect(upload.model).to eq snippet
end
end
end
end
end
describe "GET show" do describe "GET show" do
context 'Content-Disposition security measures' do context 'Content-Disposition security measures' do
let(:project) { create(:empty_project, :public) } let(:project) { create(:empty_project, :public) }
......
require 'spec_helper'
feature 'Artifact file', :js, feature: true do
let(:project) { create(:project, :public) }
let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') }
let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) }
def visit_file(path)
visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path)
end
context 'Text file' do
before do
visit_file('other_artifacts_0.1.2/doc_sample.txt')
wait_for_ajax
end
it 'displays an error' do
aggregate_failures do
# shows an error message
expect(page).to have_content('The source could not be displayed because it is stored as a job artifact. You can download it instead.')
# does not show a viewer switcher
expect(page).not_to have_selector('.js-blob-viewer-switcher')
# does not show a copy button
expect(page).not_to have_selector('.js-copy-blob-source-btn')
# shows a download button
expect(page).to have_link('Download')
end
end
end
context 'JPG file' do
before do
visit_file('rails_sample.jpg')
wait_for_ajax
end
it 'displays the blob' do
aggregate_failures do
# shows rendered image
expect(page).to have_selector('.image_file img')
# does not show a viewer switcher
expect(page).not_to have_selector('.js-blob-viewer-switcher')
# does not show a copy button
expect(page).not_to have_selector('.js-copy-blob-source-btn')
# shows a download button
expect(page).to have_link('Download')
end
end
end
end
require 'spec_helper'
describe 'New Branch Ref Dropdown', :js, :feature do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:toggle) { find('.create-from .dropdown-toggle') }
before do
project.add_master(user)
login_as(user)
visit new_namespace_project_branch_path(project.namespace, project)
end
it 'filters a list of branches and tags' do
toggle.click
filter_by('v1.0.0')
expect(items_count).to be(1)
filter_by('video')
expect(items_count).to be(1)
find('.create-from .dropdown-content li').click
expect(toggle).to have_content 'video'
end
it 'accepts a manually entered commit SHA' do
toggle.click
filter_by('somecommitsha')
find('.create-from input[type=search]').send_keys(:enter)
expect(toggle).to have_content 'somecommitsha'
end
def items_count
all('.create-from .dropdown-content li').length
end
def filter_by(filter_text)
fill_in 'Filter by Git revision', with: filter_text
end
end
require 'spec_helper' require 'spec_helper'
describe 'Project deploy keys', feature: true do describe 'Project deploy keys', :js, :feature do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project_empty_repo) } let(:project) { create(:project_empty_repo) }
...@@ -17,9 +17,13 @@ describe 'Project deploy keys', feature: true do ...@@ -17,9 +17,13 @@ describe 'Project deploy keys', feature: true do
it 'removes association between project and deploy key' do it 'removes association between project and deploy key' do
visit namespace_project_settings_repository_path(project.namespace, project) visit namespace_project_settings_repository_path(project.namespace, project)
page.within '.deploy-keys' do page.within(find('.deploy-keys')) do
expect { click_on 'Remove' } expect(page).to have_selector('.deploy-keys li', count: 1)
.to change { project.deploy_keys.count }.by(-1)
click_on 'Remove'
expect(page).not_to have_selector('.fa-spinner', count: 0)
expect(page).to have_selector('.deploy-keys li', count: 0)
end end
end end
end end
......
import Vue from 'vue';
import eventHub from '~/deploy_keys/eventhub';
import actionBtn from '~/deploy_keys/components/action_btn.vue';
describe('Deploy keys action btn', () => {
const data = getJSONFixture('deploy_keys/keys.json');
const deployKey = data.enabled_keys[0];
let vm;
beforeEach((done) => {
const ActionBtnComponent = Vue.extend(actionBtn);
vm = new ActionBtnComponent({
propsData: {
deployKey,
type: 'enable',
},
}).$mount();
setTimeout(done);
});
it('renders the type as uppercase', () => {
expect(
vm.$el.textContent.trim(),
).toBe('Enable');
});
it('sends eventHub event with btn type', (done) => {
spyOn(eventHub, '$emit');
vm.$el.click();
setTimeout(() => {
expect(
eventHub.$emit,
).toHaveBeenCalledWith('enable.key', deployKey);
done();
});
});
it('shows loading spinner after click', (done) => {
vm.$el.click();
setTimeout(() => {
expect(
vm.$el.querySelector('.fa'),
).toBeDefined();
done();
});
});
it('disables button after click', (done) => {
vm.$el.click();
setTimeout(() => {
expect(
vm.$el.classList.contains('disabled'),
).toBeTruthy();
expect(
vm.$el.getAttribute('disabled'),
).toBe('disabled');
done();
});
});
});
import Vue from 'vue';
import eventHub from '~/deploy_keys/eventhub';
import deployKeysApp from '~/deploy_keys/components/app.vue';
describe('Deploy keys app component', () => {
const data = getJSONFixture('deploy_keys/keys.json');
let vm;
const deployKeysResponse = (request, next) => {
next(request.respondWith(JSON.stringify(data), {
status: 200,
}));
};
beforeEach((done) => {
const Component = Vue.extend(deployKeysApp);
Vue.http.interceptors.push(deployKeysResponse);
vm = new Component({
propsData: {
endpoint: '/test',
},
}).$mount();
setTimeout(done);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, deployKeysResponse);
});
it('renders loading icon', (done) => {
vm.store.keys = {};
vm.isLoading = false;
Vue.nextTick(() => {
expect(
vm.$el.querySelectorAll('.deploy-keys-panel').length,
).toBe(0);
expect(
vm.$el.querySelector('.fa-spinner'),
).toBeDefined();
done();
});
});
it('renders keys panels', () => {
expect(
vm.$el.querySelectorAll('.deploy-keys-panel').length,
).toBe(3);
});
it('does not render key panels when keys object is empty', (done) => {
vm.store.keys = {};
Vue.nextTick(() => {
expect(
vm.$el.querySelectorAll('.deploy-keys-panel').length,
).toBe(0);
done();
});
});
it('does not render public panel when empty', (done) => {
vm.store.keys.public_keys = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelectorAll('.deploy-keys-panel').length,
).toBe(2);
done();
});
});
it('re-fetches deploy keys when enabling a key', (done) => {
const key = data.public_keys[0];
spyOn(vm.service, 'getKeys');
spyOn(vm.service, 'enableKey').and.callFake(() => new Promise((resolve) => {
resolve();
setTimeout(() => {
expect(vm.service.getKeys).toHaveBeenCalled();
done();
});
}));
eventHub.$emit('enable.key', key);
expect(vm.service.enableKey).toHaveBeenCalledWith(key.id);
});
it('re-fetches deploy keys when disabling a key', (done) => {
const key = data.public_keys[0];
spyOn(window, 'confirm').and.returnValue(true);
spyOn(vm.service, 'getKeys');
spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => {
resolve();
setTimeout(() => {
expect(vm.service.getKeys).toHaveBeenCalled();
done();
});
}));
eventHub.$emit('disable.key', key);
expect(vm.service.disableKey).toHaveBeenCalledWith(key.id);
});
it('calls disableKey when removing a key', (done) => {
const key = data.public_keys[0];
spyOn(window, 'confirm').and.returnValue(true);
spyOn(vm.service, 'getKeys');
spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => {
resolve();
setTimeout(() => {
expect(vm.service.getKeys).toHaveBeenCalled();
done();
});
}));
eventHub.$emit('remove.key', key);
expect(vm.service.disableKey).toHaveBeenCalledWith(key.id);
});
it('hasKeys returns true when there are keys', () => {
expect(vm.hasKeys).toEqual(3);
});
});
import Vue from 'vue';
import DeployKeysStore from '~/deploy_keys/store';
import key from '~/deploy_keys/components/key.vue';
describe('Deploy keys key', () => {
let vm;
const KeyComponent = Vue.extend(key);
const data = getJSONFixture('deploy_keys/keys.json');
const createComponent = (deployKey) => {
const store = new DeployKeysStore();
store.keys = data;
vm = new KeyComponent({
propsData: {
deployKey,
store,
},
}).$mount();
};
describe('enabled key', () => {
const deployKey = data.enabled_keys[0];
beforeEach((done) => {
createComponent(deployKey);
setTimeout(done);
});
it('renders the keys title', () => {
expect(
vm.$el.querySelector('.title').textContent.trim(),
).toContain('My title');
});
it('renders human friendly formatted created date', () => {
expect(
vm.$el.querySelector('.key-created-at').textContent.trim(),
).toBe(`created ${gl.utils.getTimeago().format(deployKey.created_at)}`);
});
it('shows remove button', () => {
expect(
vm.$el.querySelector('.btn').textContent.trim(),
).toBe('Remove');
});
it('shows write access text when key has write access', (done) => {
vm.deployKey.can_push = true;
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.write-access-allowed'),
).not.toBeNull();
expect(
vm.$el.querySelector('.write-access-allowed').textContent.trim(),
).toBe('Write access allowed');
done();
});
});
});
describe('public keys', () => {
const deployKey = data.public_keys[0];
beforeEach((done) => {
createComponent(deployKey);
setTimeout(done);
});
it('shows enable button', () => {
expect(
vm.$el.querySelector('.btn').textContent.trim(),
).toBe('Enable');
});
it('shows disable button when key is enabled', (done) => {
vm.store.keys.enabled_keys.push(deployKey);
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.btn').textContent.trim(),
).toBe('Disable');
done();
});
});
});
});
import Vue from 'vue';
import DeployKeysStore from '~/deploy_keys/store';
import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue';
describe('Deploy keys panel', () => {
const data = getJSONFixture('deploy_keys/keys.json');
let vm;
beforeEach((done) => {
const DeployKeysPanelComponent = Vue.extend(deployKeysPanel);
const store = new DeployKeysStore();
store.keys = data;
vm = new DeployKeysPanelComponent({
propsData: {
title: 'test',
keys: data.enabled_keys,
showHelpBox: true,
store,
},
}).$mount();
setTimeout(done);
});
it('renders the title with keys count', () => {
expect(
vm.$el.querySelector('h5').textContent.trim(),
).toContain('test');
expect(
vm.$el.querySelector('h5').textContent.trim(),
).toContain(`(${vm.keys.length})`);
});
it('renders list of keys', () => {
expect(
vm.$el.querySelectorAll('li').length,
).toBe(vm.keys.length);
});
it('renders help box if keys are empty', (done) => {
vm.keys = [];
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.settings-message'),
).toBeDefined();
expect(
vm.$el.querySelector('.settings-message').textContent.trim(),
).toBe('No deploy keys found. Create one with the form above.');
done();
});
});
it('does not render help box if keys are empty & showHelpBox is false', (done) => {
vm.keys = [];
vm.showHelpBox = false;
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.settings-message'),
).toBeNull();
done();
});
});
});
...@@ -4,7 +4,6 @@ import actionsComp from '~/environments/components/environment_actions.vue'; ...@@ -4,7 +4,6 @@ import actionsComp from '~/environments/components/environment_actions.vue';
describe('Actions Component', () => { describe('Actions Component', () => {
let ActionsComponent; let ActionsComponent;
let actionsMock; let actionsMock;
let spy;
let component; let component;
beforeEach(() => { beforeEach(() => {
...@@ -26,13 +25,9 @@ describe('Actions Component', () => { ...@@ -26,13 +25,9 @@ describe('Actions Component', () => {
}, },
]; ];
spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
component = new ActionsComponent({ component = new ActionsComponent({
propsData: { propsData: {
actions: actionsMock, actions: actionsMock,
service: {
postAction: spy,
},
}, },
}).$mount(); }).$mount();
}); });
...@@ -48,13 +43,6 @@ describe('Actions Component', () => { ...@@ -48,13 +43,6 @@ describe('Actions Component', () => {
).toEqual(actionsMock.length); ).toEqual(actionsMock.length);
}); });
it('should call the service when an action is clicked', () => {
component.$el.querySelector('.dropdown').click();
component.$el.querySelector('.js-manual-action-link').click();
expect(spy).toHaveBeenCalledWith(actionsMock[0].play_path);
});
it('should render a disabled action when it\'s not playable', () => { it('should render a disabled action when it\'s not playable', () => {
expect( expect(
component.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'), component.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'),
......
...@@ -4,11 +4,9 @@ import rollbackComp from '~/environments/components/environment_rollback.vue'; ...@@ -4,11 +4,9 @@ import rollbackComp from '~/environments/components/environment_rollback.vue';
describe('Rollback Component', () => { describe('Rollback Component', () => {
const retryURL = 'https://gitlab.com/retry'; const retryURL = 'https://gitlab.com/retry';
let RollbackComponent; let RollbackComponent;
let spy;
beforeEach(() => { beforeEach(() => {
RollbackComponent = Vue.extend(rollbackComp); RollbackComponent = Vue.extend(rollbackComp);
spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
}); });
it('Should render Re-deploy label when isLastDeployment is true', () => { it('Should render Re-deploy label when isLastDeployment is true', () => {
...@@ -17,9 +15,6 @@ describe('Rollback Component', () => { ...@@ -17,9 +15,6 @@ describe('Rollback Component', () => {
propsData: { propsData: {
retryUrl: retryURL, retryUrl: retryURL,
isLastDeployment: true, isLastDeployment: true,
service: {
postAction: spy,
},
}, },
}).$mount(); }).$mount();
...@@ -32,28 +27,9 @@ describe('Rollback Component', () => { ...@@ -32,28 +27,9 @@ describe('Rollback Component', () => {
propsData: { propsData: {
retryUrl: retryURL, retryUrl: retryURL,
isLastDeployment: false, isLastDeployment: false,
service: {
postAction: spy,
},
}, },
}).$mount(); }).$mount();
expect(component.$el.querySelector('span').textContent).toContain('Rollback'); expect(component.$el.querySelector('span').textContent).toContain('Rollback');
}); });
it('should call the service when the button is clicked', () => {
const component = new RollbackComponent({
propsData: {
retryUrl: retryURL,
isLastDeployment: false,
service: {
postAction: spy,
},
},
}).$mount();
component.$el.click();
expect(spy).toHaveBeenCalledWith(retryURL);
});
}); });
...@@ -4,20 +4,15 @@ import stopComp from '~/environments/components/environment_stop.vue'; ...@@ -4,20 +4,15 @@ import stopComp from '~/environments/components/environment_stop.vue';
describe('Stop Component', () => { describe('Stop Component', () => {
let StopComponent; let StopComponent;
let component; let component;
let spy;
const stopURL = '/stop'; const stopURL = '/stop';
beforeEach(() => { beforeEach(() => {
StopComponent = Vue.extend(stopComp); StopComponent = Vue.extend(stopComp);
spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve());
spyOn(window, 'confirm').and.returnValue(true); spyOn(window, 'confirm').and.returnValue(true);
component = new StopComponent({ component = new StopComponent({
propsData: { propsData: {
stopUrl: stopURL, stopUrl: stopURL,
service: {
postAction: spy,
},
}, },
}).$mount(); }).$mount();
}); });
...@@ -26,9 +21,4 @@ describe('Stop Component', () => { ...@@ -26,9 +21,4 @@ describe('Stop Component', () => {
expect(component.$el.tagName).toEqual('BUTTON'); expect(component.$el.tagName).toEqual('BUTTON');
expect(component.$el.getAttribute('title')).toEqual('Stop'); expect(component.$el.getAttribute('title')).toEqual('Stop');
}); });
it('should call the service when an action is clicked', () => {
component.$el.click();
expect(spy).toHaveBeenCalled();
});
}); });
require 'spec_helper'
describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :controller do
include JavaScriptFixturesHelpers
let(:admin) { create(:admin) }
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') }
let(:project2) { create(:empty_project, :internal)}
before(:all) do
clean_frontend_fixtures('deploy_keys/')
end
before(:each) do
sign_in(admin)
end
render_views
it 'deploy_keys/keys.json' do |example|
create(:deploy_key, public: true)
project_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com')
internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com')
create(:deploy_keys_project, project: project, deploy_key: project_key)
create(:deploy_keys_project, project: project2, deploy_key: internal_key)
get :index,
namespace_id: project.namespace.to_param,
project_id: project,
format: :json
expect(response).to be_success
store_frontend_fixture(response, example.description)
end
end
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
Dropdown Dropdown
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
.js-builds-dropdown-list.scrollable-menu %li.js-builds-dropdown-list.scrollable-menu
.js-builds-dropdown-loading.builds-dropdown-loading.hidden %li.js-builds-dropdown-loading.hidden
%span.fa.fa-spinner.fa-spin %span.fa.fa-spinner
import Vue from 'vue'; import Vue from 'vue';
import { SUCCESS_SVG } from '~/ci_status_icons'; import stage from '~/pipelines/components/stage.vue';
import Stage from '~/pipelines/components/stage';
describe('Pipelines stage component', () => {
let StageComponent;
let component;
beforeEach(() => {
StageComponent = Vue.extend(stage);
component = new StageComponent({
propsData: {
stage: {
status: {
group: 'success',
icon: 'icon_status_success',
title: 'success',
},
dropdown_path: 'foo',
},
updateDropdown: false,
},
}).$mount();
});
function minify(string) { it('should render a dropdown with the status icon', () => {
return string.replace(/\s/g, ''); expect(component.$el.getAttribute('class')).toEqual('dropdown');
} expect(component.$el.querySelector('svg')).toBeDefined();
expect(component.$el.querySelector('button').getAttribute('data-toggle')).toEqual('dropdown');
});
describe('Pipelines Stage', () => { describe('with successfull request', () => {
describe('data', () => { const interceptor = (request, next) => {
let stageReturnValue; next(request.respondWith(JSON.stringify({ html: 'foo' }), {
status: 200,
}));
};
beforeEach(() => { beforeEach(() => {
stageReturnValue = Stage.data(); Vue.http.interceptors.push(interceptor);
}); });
it('should return object with .builds and .spinner', () => { afterEach(() => {
expect(stageReturnValue).toEqual({ Vue.http.interceptors = _.without(
builds: '', Vue.http.interceptors, interceptor,
spinner: '<span class="fa fa-spinner fa-spin"></span>', );
});
}); });
});
describe('computed', () => { it('should render the received data', (done) => {
describe('svgHTML', function () { component.$el.querySelector('button').click();
let stage;
let svgHTML;
beforeEach(() => { setTimeout(() => {
stage = { stage: { status: { icon: 'icon_status_success' } } }; expect(
component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(),
svgHTML = Stage.computed.svgHTML.call(stage); ).toEqual('foo');
}); done();
}, 0);
it("should return the correct icon for the stage's status", () => {
expect(svgHTML).toBe(SUCCESS_SVG);
});
}); });
}); });
describe('when mounted', () => { describe('when request fails', () => {
let StageComponent; const interceptor = (request, next) => {
let renderedComponent; next(request.respondWith(JSON.stringify({}), {
let stage; status: 500,
}));
};
beforeEach(() => { beforeEach(() => {
stage = { status: { icon: 'icon_status_success' } }; Vue.http.interceptors.push(interceptor);
StageComponent = Vue.extend(Stage);
renderedComponent = new StageComponent({
propsData: {
stage,
},
}).$mount();
}); });
it('should render the correct status svg', () => { afterEach(() => {
const minifiedComponent = minify(renderedComponent.$el.outerHTML); Vue.http.interceptors = _.without(
const expectedSVG = minify(SUCCESS_SVG); Vue.http.interceptors, interceptor,
);
expect(minifiedComponent).toContain(expectedSVG);
}); });
});
describe('when request fails', () => {
it('closes dropdown', () => {
spyOn($, 'ajax').and.callFake(options => options.error());
const StageComponent = Vue.extend(Stage);
const component = new StageComponent({ it('should close the dropdown', () => {
propsData: { stage: { status: { icon: 'foo' } } }, component.$el.click();
}).$mount();
expect( setTimeout(() => {
component.$el.classList.contains('open'), expect(component.$el.classList.contains('open')).toEqual(false);
).toEqual(false); }, 0);
}); });
}); });
}); });
...@@ -135,6 +135,17 @@ describe Gitlab::Ci::Build::Artifacts::Metadata::Entry do ...@@ -135,6 +135,17 @@ describe Gitlab::Ci::Build::Artifacts::Metadata::Entry do
subject { |example| path(example).nodes } subject { |example| path(example).nodes }
it { is_expected.to eq 4 } it { is_expected.to eq 4 }
end end
describe '#blob' do
let(:file_entry) { |example| path(example) }
subject { file_entry.blob }
it 'returns a blob representing the entry data' do
expect(subject).to be_a(Blob)
expect(subject.path).to eq(file_entry.path)
expect(subject.size).to eq(file_entry.metadata[:size])
end
end
end end
describe 'non-existent/', path: 'non-existent/' do describe 'non-existent/', path: 'non-existent/' do
......
...@@ -1062,7 +1062,7 @@ describe Gitlab::Git::Repository, seed_helper: true do ...@@ -1062,7 +1062,7 @@ describe Gitlab::Git::Repository, seed_helper: true do
end end
it "allows ordering by date" do it "allows ordering by date" do
expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE) expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE | Rugged::SORT_TOPO)
repository.find_commits(order: :date) repository.find_commits(order: :date)
end end
......
...@@ -351,6 +351,7 @@ Project: ...@@ -351,6 +351,7 @@ Project:
- auto_cancel_pending_pipelines - auto_cancel_pending_pipelines
- printing_merge_request_link_enabled - printing_merge_request_link_enabled
- build_allow_git_fetch - build_allow_git_fetch
- last_repository_updated_at
Author: Author:
- name - name
ProjectFeature: ProjectFeature:
......
require 'spec_helper'
describe Ci::ArtifactBlob, models: true do
let(:build) { create(:ci_build, :artifacts) }
let(:entry) { build.artifacts_metadata_entry('other_artifacts_0.1.2/another-subdirectory/banana_sample.gif') }
subject { described_class.new(entry) }
describe '#id' do
it 'returns a hash of the path' do
expect(subject.id).to eq(Digest::SHA1.hexdigest(entry.path))
end
end
describe '#name' do
it 'returns the entry name' do
expect(subject.name).to eq(entry.name)
end
end
describe '#path' do
it 'returns the entry path' do
expect(subject.path).to eq(entry.path)
end
end
describe '#size' do
it 'returns the entry size' do
expect(subject.size).to eq(entry.metadata[:size])
end
end
describe '#mode' do
it 'returns the entry mode' do
expect(subject.mode).to eq(entry.metadata[:mode])
end
end
describe '#external_storage' do
it 'returns :build_artifact' do
expect(subject.external_storage).to eq(:build_artifact)
end
end
end
...@@ -15,13 +15,39 @@ describe Event, models: true do ...@@ -15,13 +15,39 @@ describe Event, models: true do
end end
describe 'Callbacks' do describe 'Callbacks' do
describe 'after_create :reset_project_activity' do let(:project) { create(:empty_project) }
let(:project) { create(:empty_project) }
describe 'after_create :reset_project_activity' do
it 'calls the reset_project_activity method' do it 'calls the reset_project_activity method' do
expect_any_instance_of(described_class).to receive(:reset_project_activity) expect_any_instance_of(described_class).to receive(:reset_project_activity)
create_event(project, project.owner) create_push_event(project, project.owner)
end
end
describe 'after_create :set_last_repository_updated_at' do
context 'with a push event' do
it 'updates the project last_repository_updated_at' do
project.update(last_repository_updated_at: 1.year.ago)
create_push_event(project, project.owner)
project.reload
expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
end
end
context 'without a push event' do
it 'does not update the project last_repository_updated_at' do
project.update(last_repository_updated_at: 1.year.ago)
create(:closed_issue_event, project: project, author: project.owner)
project.reload
expect(project.last_repository_updated_at).to be_within(1.minute).of(1.year.ago)
end
end end
end end
end end
...@@ -29,7 +55,7 @@ describe Event, models: true do ...@@ -29,7 +55,7 @@ describe Event, models: true do
describe "Push event" do describe "Push event" do
let(:project) { create(:empty_project, :private) } let(:project) { create(:empty_project, :private) }
let(:user) { project.owner } let(:user) { project.owner }
let(:event) { create_event(project, user) } let(:event) { create_push_event(project, user) }
it do it do
expect(event.push?).to be_truthy expect(event.push?).to be_truthy
...@@ -243,7 +269,7 @@ describe Event, models: true do ...@@ -243,7 +269,7 @@ describe Event, models: true do
expect(project).not_to receive(:update_column). expect(project).not_to receive(:update_column).
with(:last_activity_at, a_kind_of(Time)) with(:last_activity_at, a_kind_of(Time))
create_event(project, project.owner) create_push_event(project, project.owner)
end end
end end
...@@ -251,11 +277,11 @@ describe Event, models: true do ...@@ -251,11 +277,11 @@ describe Event, models: true do
it 'updates the project' do it 'updates the project' do
project.update(last_activity_at: 1.year.ago) project.update(last_activity_at: 1.year.ago)
create_event(project, project.owner) create_push_event(project, project.owner)
project.reload project.reload
project.last_activity_at <= 1.minute.ago expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
end end
end end
end end
...@@ -278,7 +304,7 @@ describe Event, models: true do ...@@ -278,7 +304,7 @@ describe Event, models: true do
end end
end end
def create_event(project, user, attrs = {}) def create_push_event(project, user, attrs = {})
data = { data = {
before: Gitlab::Git::BLANK_SHA, before: Gitlab::Git::BLANK_SHA,
after: "0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e", after: "0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e",
......
...@@ -10,17 +10,17 @@ describe Network::Graph, models: true do ...@@ -10,17 +10,17 @@ describe Network::Graph, models: true do
expect(graph.notes).to eq( { note_on_commit.commit_id => 1 } ) expect(graph.notes).to eq( { note_on_commit.commit_id => 1 } )
end end
describe "#commits" do describe '#commits' do
let(:graph) { described_class.new(project, 'refs/heads/master', project.repository.commit, nil) } let(:graph) { described_class.new(project, 'refs/heads/master', project.repository.commit, nil) }
it "returns a list of commits" do it 'returns a list of commits' do
commits = graph.commits commits = graph.commits
expect(commits).not_to be_empty expect(commits).not_to be_empty
expect(commits).to all( be_kind_of(Network::Commit) ) expect(commits).to all( be_kind_of(Network::Commit) )
end end
it "sorts the commits by commit date (descending)" do it 'it the commits by commit date (descending)' do
# Remove duplicate timestamps because they make it harder to # Remove duplicate timestamps because they make it harder to
# assert that the commits are sorted as expected. # assert that the commits are sorted as expected.
commits = graph.commits.uniq(&:date) commits = graph.commits.uniq(&:date)
...@@ -29,5 +29,20 @@ describe Network::Graph, models: true do ...@@ -29,5 +29,20 @@ describe Network::Graph, models: true do
expect(commits).not_to be_empty expect(commits).not_to be_empty
expect(commits.map(&:id)).to eq(sorted_commits.map(&:id)) expect(commits.map(&:id)).to eq(sorted_commits.map(&:id))
end end
it 'sorts children before parents for commits with the same timestamp' do
commits_by_time = graph.commits.group_by(&:date)
commits_by_time.each do |time, commits|
commit_ids = commits.map(&:id)
commits.each_with_index do |commit, index|
parent_indexes = commit.parent_ids.map { |parent_id| commit_ids.find_index(parent_id) }.compact
# All parents of the current commit should appear after it
expect(parent_indexes).to all( be > index )
end
end
end
end end
end end
...@@ -1925,4 +1925,12 @@ describe Project, models: true do ...@@ -1925,4 +1925,12 @@ describe Project, models: true do
not_to raise_error not_to raise_error
end end
end end
describe '#last_repository_updated_at' do
it 'sets to created_at upon creation' do
project = create(:empty_project, created_at: 2.hours.ago)
expect(project.last_repository_updated_at.to_i).to eq(project.created_at.to_i)
end
end
end end
...@@ -213,9 +213,12 @@ describe ProjectWiki, models: true do ...@@ -213,9 +213,12 @@ describe ProjectWiki, models: true do
end end
it 'updates project activity' do it 'updates project activity' do
expect(subject).to receive(:update_project_activity)
subject.create_page('Test Page', 'This is content') subject.create_page('Test Page', 'This is content')
project.reload
expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
end end
end end
...@@ -240,9 +243,12 @@ describe ProjectWiki, models: true do ...@@ -240,9 +243,12 @@ describe ProjectWiki, models: true do
end end
it 'updates project activity' do it 'updates project activity' do
expect(subject).to receive(:update_project_activity)
subject.update_page(@gollum_page, 'Yet more content', :markdown, 'Updated page again') subject.update_page(@gollum_page, 'Yet more content', :markdown, 'Updated page again')
project.reload
expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
end end
end end
...@@ -258,9 +264,12 @@ describe ProjectWiki, models: true do ...@@ -258,9 +264,12 @@ describe ProjectWiki, models: true do
end end
it 'updates project activity' do it 'updates project activity' do
expect(subject).to receive(:update_project_activity)
subject.delete_page(@page) subject.delete_page(@page)
project.reload
expect(project.last_activity_at).to be_within(1.minute).of(Time.now)
expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now)
end end
end end
......
require 'spec_helper'
describe PersonalSnippetPolicy, models: true do
let(:regular_user) { create(:user) }
let(:external_user) { create(:user, :external) }
let(:admin_user) { create(:user, :admin) }
let(:author_permissions) do
[
:update_personal_snippet,
:admin_personal_snippet,
:destroy_personal_snippet
]
end
def permissions(user)
described_class.abilities(user, snippet).to_set
end
context 'public snippet' do
let(:snippet) { create(:personal_snippet, :public) }
context 'no user' do
subject { permissions(nil) }
it do
is_expected.to include(:read_personal_snippet)
is_expected.not_to include(:comment_personal_snippet)
is_expected.not_to include(*author_permissions)
end
end
context 'regular user' do
subject { permissions(regular_user) }
it do
is_expected.to include(:read_personal_snippet)
is_expected.to include(:comment_personal_snippet)
is_expected.not_to include(*author_permissions)
end
end
context 'author' do
subject { permissions(snippet.author) }
it do
is_expected.to include(:read_personal_snippet)
is_expected.to include(:comment_personal_snippet)
is_expected.to include(*author_permissions)
end
end
end
context 'internal snippet' do
let(:snippet) { create(:personal_snippet, :internal) }
context 'no user' do
subject { permissions(nil) }
it do
is_expected.not_to include(:read_personal_snippet)
is_expected.not_to include(:comment_personal_snippet)
is_expected.not_to include(*author_permissions)
end
end
context 'regular user' do
subject { permissions(regular_user) }
it do
is_expected.to include(:read_personal_snippet)
is_expected.to include(:comment_personal_snippet)
is_expected.not_to include(*author_permissions)
end
end
context 'external user' do
subject { permissions(external_user) }
it do
is_expected.not_to include(:read_personal_snippet)
is_expected.not_to include(:comment_personal_snippet)
is_expected.not_to include(*author_permissions)
end
end
context 'snippet author' do
subject { permissions(snippet.author) }
it do
is_expected.to include(:read_personal_snippet)
is_expected.to include(:comment_personal_snippet)
is_expected.to include(*author_permissions)
end
end
end
context 'private snippet' do
let(:snippet) { create(:project_snippet, :private) }
context 'no user' do
subject { permissions(nil) }
it do
is_expected.not_to include(:read_personal_snippet)
is_expected.not_to include(:comment_personal_snippet)
is_expected.not_to include(*author_permissions)
end
end
context 'regular user' do
subject { permissions(regular_user) }
it do
is_expected.not_to include(:read_personal_snippet)
is_expected.not_to include(:comment_personal_snippet)
is_expected.not_to include(*author_permissions)
end
end
context 'external user' do
subject { permissions(external_user) }
it do
is_expected.not_to include(:read_personal_snippet)
is_expected.not_to include(:comment_personal_snippet)
is_expected.not_to include(*author_permissions)
end
end
context 'snippet author' do
subject { permissions(snippet.author) }
it do
is_expected.to include(:read_personal_snippet)
is_expected.to include(:comment_personal_snippet)
is_expected.to include(*author_permissions)
end
end
end
end
require 'spec_helper'
describe DeployKeyEntity do
include RequestAwareEntity
let(:user) { create(:user) }
let(:project) { create(:empty_project, :internal)}
let(:project_private) { create(:empty_project, :private)}
let(:deploy_key) { create(:deploy_key) }
let!(:deploy_key_internal) { create(:deploy_keys_project, project: project, deploy_key: deploy_key) }
let!(:deploy_key_private) { create(:deploy_keys_project, project: project_private, deploy_key: deploy_key) }
let(:entity) { described_class.new(deploy_key, user: user) }
it 'returns deploy keys with projects a user can read' do
expected_result = {
id: deploy_key.id,
user_id: deploy_key.user_id,
title: deploy_key.title,
fingerprint: deploy_key.fingerprint,
can_push: deploy_key.can_push,
destroyed_when_orphaned: true,
almost_orphaned: false,
created_at: deploy_key.created_at,
updated_at: deploy_key.updated_at,
projects: [
{
id: project.id,
name: project.name,
full_path: namespace_project_path(project.namespace, project),
full_name: project.full_name
}
]
}
expect(entity.as_json).to eq(expected_result)
end
end
require 'spec_helper' require 'spec_helper'
describe Projects::UploadService, services: true do describe UploadService, services: true do
describe 'File service' do describe 'File service' do
before do before do
@user = create(:user) @user = create(:user)
...@@ -68,6 +68,6 @@ describe Projects::UploadService, services: true do ...@@ -68,6 +68,6 @@ describe Projects::UploadService, services: true do
end end
def upload_file(project, file) def upload_file(project, file)
Projects::UploadService.new(project, file).execute described_class.new(project, file, FileUploader).execute
end end
end end
...@@ -11,6 +11,10 @@ describe 'gitlab:shell rake tasks' do ...@@ -11,6 +11,10 @@ describe 'gitlab:shell rake tasks' do
it 'invokes create_hooks task' do it 'invokes create_hooks task' do
expect(Rake::Task['gitlab:shell:create_hooks']).to receive(:invoke) expect(Rake::Task['gitlab:shell:create_hooks']).to receive(:invoke)
storages = Gitlab.config.repositories.storages.values.map { |rs| rs['path'] }
expect(Kernel).to receive(:system).with('bin/install', *storages).and_call_original
expect(Kernel).to receive(:system).with('bin/compile').and_call_original
run_rake_task('gitlab:shell:install') run_rake_task('gitlab:shell:install')
end end
end end
......
require 'spec_helper'
describe PersonalFileUploader do
let(:uploader) { described_class.new(build_stubbed(:empty_project)) }
let(:snippet) { create(:personal_snippet) }
describe '.absolute_path' do
it 'returns the correct absolute path by building it dynamically' do
upload = double(model: snippet, path: 'secret/foo.jpg')
dynamic_segment = "personal_snippet/#{snippet.id}"
expect(described_class.absolute_path(upload)).to end_with("#{dynamic_segment}/secret/foo.jpg")
end
end
describe '#to_h' do
it 'returns the hass' do
uploader = described_class.new(snippet, 'secret')
allow(uploader).to receive(:file).and_return(double(extension: 'txt', filename: 'file_name'))
expected_url = "/uploads/personal_snippet/#{snippet.id}/secret/file_name"
expect(uploader.to_h).to eq(
alt: 'file_name',
url: expected_url,
markdown: "[file_name](#{expected_url})"
)
end
end
end
require 'spec_helper'
describe 'projects/tags/index', :view do
let(:project) { create(:project) }
before do
assign(:project, project)
assign(:repository, project.repository)
assign(:tags, [])
allow(view).to receive(:current_ref).and_return('master')
allow(view).to receive(:can?).and_return(false)
end
it 'defaults sort dropdown toggle to last updated' do
render
expect(rendered).to have_button('Last updated')
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment