Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ce into mia_backort[ci skip]
This commit is contained in:
commit
5004579b15
|
|
@ -7,3 +7,4 @@
|
|||
/vendor/
|
||||
karma.config.js
|
||||
webpack.config.js
|
||||
/app/assets/javascripts/locale/**/*.js
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
*.log
|
||||
*.swp
|
||||
*.mo
|
||||
*.edit.po
|
||||
.DS_Store
|
||||
.bundle
|
||||
.chef
|
||||
|
|
@ -54,3 +56,4 @@ eslint-report.html
|
|||
/shared/*
|
||||
/.gitlab_workhorse_secret
|
||||
/webpack-report/
|
||||
/locale/**/LC_MESSAGES
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ before_script:
|
|||
- source scripts/prepare_build.sh
|
||||
|
||||
stages:
|
||||
- build
|
||||
- prepare
|
||||
- test
|
||||
- post-test
|
||||
|
|
@ -137,6 +138,28 @@ stages:
|
|||
<<: *only-master-and-ee-or-mysql
|
||||
<<: *except-docs
|
||||
|
||||
# Trigger a package build on omnibus-gitlab repository
|
||||
|
||||
build-package:
|
||||
services: []
|
||||
variables:
|
||||
SETUP_DB: "false"
|
||||
USE_BUNDLE_INSTALL: "false"
|
||||
stage: build
|
||||
when: manual
|
||||
script:
|
||||
# If no branch in omnibus is specified, trigger pipeline against master
|
||||
- if [ -z "$OMNIBUS_BRANCH" ] ; then export OMNIBUS_BRANCH=master ;fi
|
||||
- echo "token=${BUILD_TRIGGER_TOKEN}" > version_details
|
||||
- echo "ref=${OMNIBUS_BRANCH}" >> version_details
|
||||
- echo "variables[ALTERNATIVE_SOURCES]=true" >> version_details
|
||||
- echo "variables[GITLAB_VERSION]=${CI_COMMIT_SHA}" >> version_details
|
||||
# Collect version details of all components
|
||||
- for f in *_VERSION; do echo "variables[$f]=$(cat $f)" >> version_details; done
|
||||
# Trigger the API and pass values collected above as parameters to it
|
||||
- cat version_details | tr '\n' '&' | curl -X POST https://gitlab.com/api/v4/projects/20699/trigger/pipeline --data-binary @-
|
||||
- rm version_details
|
||||
|
||||
# Prepare and merge knapsack tests
|
||||
knapsack:
|
||||
<<: *knapsack-state
|
||||
|
|
@ -412,18 +435,6 @@ rake karma:
|
|||
paths:
|
||||
- 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
|
||||
stage: test
|
||||
<<: *dedicated-runner
|
||||
|
|
|
|||
243
CONTRIBUTING.md
243
CONTRIBUTING.md
|
|
@ -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 -->
|
||||
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
|
||||
|
||||
- [Contributor license agreement](#contributor-license-agreement)
|
||||
- [Contribute to GitLab](#contribute-to-gitlab)
|
||||
- [Security vulnerability disclosure](#security-vulnerability-disclosure)
|
||||
- [Closing policy for issues and merge requests](#closing-policy-for-issues-and-merge-requests)
|
||||
- [Helping others](#helping-others)
|
||||
- [I want to contribute!](#i-want-to-contribute)
|
||||
- [Implement design & UI elements](#implement-design-ui-elements)
|
||||
- [Release retrospective and kickoff](#release-retrospective-and-kickoff)
|
||||
- [Retrospective](#retrospective)
|
||||
- [Kickoff](#kickoff)
|
||||
- [Workflow labels](#workflow-labels)
|
||||
- [Type labels (~"feature proposal", ~bug, ~customer, etc.)](#type-labels-feature-proposal-bug-customer-etc)
|
||||
- [Subject labels (~wiki, ~"container registry", ~ldap, ~api, etc.)](#subject-labels-wiki-container-registry-ldap-api-etc)
|
||||
- [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)
|
||||
- [Feature proposals](#feature-proposals)
|
||||
- [Issue tracker guidelines](#issue-tracker-guidelines)
|
||||
- [Issue weight](#issue-weight)
|
||||
- [Regression issues](#regression-issues)
|
||||
- [Technical debt](#technical-debt)
|
||||
- [Stewardship](#stewardship)
|
||||
- [Issue triaging](#issue-triaging)
|
||||
- [Feature proposals](#feature-proposals)
|
||||
- [Issue tracker guidelines](#issue-tracker-guidelines)
|
||||
- [Issue weight](#issue-weight)
|
||||
- [Regression issues](#regression-issues)
|
||||
- [Technical debt](#technical-debt)
|
||||
- [Stewardship](#stewardship)
|
||||
- [Merge requests](#merge-requests)
|
||||
- [Merge request guidelines](#merge-request-guidelines)
|
||||
- [Contribution acceptance criteria](#contribution-acceptance-criteria)
|
||||
- [Changes for Stable Releases](#changes-for-stable-releases)
|
||||
- [Merge request guidelines](#merge-request-guidelines)
|
||||
- [Contribution acceptance criteria](#contribution-acceptance-criteria)
|
||||
- [Definition of done](#definition-of-done)
|
||||
- [Style guides](#style-guides)
|
||||
- [Code of conduct](#code-of-conduct)
|
||||
|
|
@ -103,35 +105,126 @@ contributing to GitLab.
|
|||
|
||||
## 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.
|
||||
|
||||
Most issues will have labels for at least one of the following:
|
||||
|
||||
- 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)
|
||||
|
||||
Priority labels help us clearly communicate expectations of the work for the
|
||||
release. There are two levels of priority labels:
|
||||
|
||||
- ~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.
|
||||
|
||||
### Label for community contributors (~"Accepting Merge Requests")
|
||||
|
||||
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.
|
||||
|
||||
Community contributors can submit merge requests for any issue they want, but
|
||||
the ~"Accepting Merge Requests" label has a special meaning. It points to
|
||||
changes that:
|
||||
|
||||
1. We already agreed on,
|
||||
1. Are well-defined,
|
||||
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].
|
||||
|
||||
## 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
|
||||
|
||||
## Issue tracker
|
||||
|
||||
To get support for your particular problem please use the
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
To create a feature proposal for CE, open an issue on the
|
||||
|
|
@ -327,13 +435,17 @@ request is as follows:
|
|||
"Description" field.
|
||||
1. If you are contributing documentation, choose `Documentation` from the
|
||||
"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 CSS classes please include the list of affected pages,
|
||||
`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
|
||||
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
|
||||
handles paths to files on disk, make sure it adheres to the
|
||||
[shell command guidelines](doc/development/shell_commands.md)
|
||||
|
|
@ -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
|
||||
[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
|
||||
|
||||
1. The change is as small as possible
|
||||
|
|
@ -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
|
||||
[polling with ETag caching][polling-etag].
|
||||
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
|
||||
over, before merging.
|
||||
(no squashing).
|
||||
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
|
||||
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:
|
|||
See the instructions in that document for help if your MR fails the
|
||||
"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
|
||||
|
||||
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.
|
|||
|
||||
1. Description explaining the relevancy (see following item)
|
||||
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. [Documented][doc-styleguide] in the /doc directory
|
||||
1. Changelog entry added
|
||||
1. [Documented][doc-styleguide] in the `/doc` directory
|
||||
1. [Changelog entry added][changelog], if necessary
|
||||
1. Reviewed and any concerns are addressed
|
||||
1. Merged by the project lead
|
||||
1. Added to the release blog article
|
||||
1. Added to [the website](https://gitlab.com/gitlab-com/www-gitlab-com/) if relevant
|
||||
1. Merged by a project maintainer
|
||||
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. 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
|
||||
consider updating the following and note the applicability of each in your
|
||||
|
|
@ -481,7 +561,7 @@ merge request:
|
|||
- string literal quoting style **Option A**: single quoted by default
|
||||
1. [Rails](https://github.com/bbatsov/rails-style-guide)
|
||||
1. [Newlines styleguide][newlines-styleguide]
|
||||
1. [Testing](doc/development/testing.md)
|
||||
1. [Testing][testing]
|
||||
1. [JavaScript styleguide][js-styleguide]
|
||||
1. [SCSS styleguide][scss-styleguide]
|
||||
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
|
|||
[license-finder-doc]: doc/development/licensing.md
|
||||
[GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues
|
||||
[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
|
||||
code.
|
||||
|
|
|
|||
5
Gemfile
5
Gemfile
|
|
@ -256,6 +256,11 @@ gem 'sentry-raven', '~> 2.4.0'
|
|||
|
||||
gem 'premailer-rails', '~> 1.9.0'
|
||||
|
||||
# I18n
|
||||
gem 'gettext_i18n_rails', '~> 1.8.0'
|
||||
gem 'gettext_i18n_rails_js', '~> 1.2.0'
|
||||
gem 'gettext', '~> 3.2.2', require: false, group: :development
|
||||
|
||||
# Metrics
|
||||
group :metrics do
|
||||
gem 'allocations', '~> 1.0', require: false, platform: :mri
|
||||
|
|
|
|||
18
Gemfile.lock
18
Gemfile.lock
|
|
@ -198,6 +198,7 @@ GEM
|
|||
faraday_middleware-multi_json (0.0.6)
|
||||
faraday_middleware
|
||||
multi_json
|
||||
fast_gettext (1.4.0)
|
||||
ffaker (2.4.0)
|
||||
ffi (1.9.10)
|
||||
flay (2.8.1)
|
||||
|
|
@ -251,6 +252,16 @@ GEM
|
|||
gemojione (3.0.1)
|
||||
json
|
||||
get_process_mem (0.2.0)
|
||||
gettext (3.2.2)
|
||||
locale (>= 2.0.5)
|
||||
text (>= 1.3.0)
|
||||
gettext_i18n_rails (1.8.0)
|
||||
fast_gettext (>= 0.9.0)
|
||||
gettext_i18n_rails_js (1.2.0)
|
||||
gettext (>= 3.0.2)
|
||||
gettext_i18n_rails (>= 0.7.1)
|
||||
po_to_json (>= 1.0.0)
|
||||
rails (>= 3.2.0)
|
||||
gherkin-ruby (0.3.2)
|
||||
gitaly (0.5.0)
|
||||
google-protobuf (~> 3.1)
|
||||
|
|
@ -422,6 +433,7 @@ GEM
|
|||
licensee (8.7.0)
|
||||
rugged (~> 0.24)
|
||||
little-plugger (1.1.4)
|
||||
locale (2.1.2)
|
||||
logging (2.1.0)
|
||||
little-plugger (~> 1.1)
|
||||
multi_json (~> 1.10)
|
||||
|
|
@ -525,6 +537,8 @@ GEM
|
|||
ast (~> 2.2)
|
||||
path_expander (1.0.1)
|
||||
pg (0.18.4)
|
||||
po_to_json (1.0.1)
|
||||
json (>= 1.6.0)
|
||||
poltergeist (1.9.0)
|
||||
capybara (~> 2.1)
|
||||
cliver (~> 0.3.1)
|
||||
|
|
@ -777,6 +791,7 @@ GEM
|
|||
temple (0.7.7)
|
||||
test_after_commit (1.1.0)
|
||||
activerecord (>= 3.2)
|
||||
text (1.3.1)
|
||||
thin (1.7.0)
|
||||
daemons (~> 1.0, >= 1.0.9)
|
||||
eventmachine (~> 1.0, >= 1.0.4)
|
||||
|
|
@ -904,6 +919,9 @@ DEPENDENCIES
|
|||
fuubar (~> 2.0.0)
|
||||
gemnasium-gitlab-service (~> 0.2)
|
||||
gemojione (~> 3.0)
|
||||
gettext (~> 3.2.2)
|
||||
gettext_i18n_rails (~> 1.8.0)
|
||||
gettext_i18n_rails_js (~> 1.2.0)
|
||||
gitaly (~> 0.5.0)
|
||||
github-linguist (~> 4.7.0)
|
||||
gitlab-flowdock-git-hook (~> 1.0.1)
|
||||
|
|
|
|||
95
PROCESS.md
95
PROCESS.md
|
|
@ -1,35 +1,53 @@
|
|||
# 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
|
||||
|
||||
Below we describe the contributing process to GitLab for two reasons. So that
|
||||
contributors know what to expect from maintainers (possible responses, friendly
|
||||
treatment, etc.). And so that maintainers know what to expect from contributors
|
||||
(use the latest version, ensure that the issue is addressed, friendly treatment,
|
||||
etc.).
|
||||
Below we describe the contributing process to GitLab for two reasons:
|
||||
|
||||
1. Contributors know what to expect from maintainers (possible responses, friendly
|
||||
treatment, 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/)
|
||||
|
||||
## 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
|
||||
|
||||
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].
|
|||
|
||||
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
|
||||
|
||||
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
|
|||
Merge requests without a milestone and this label will
|
||||
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
|
||||
|
||||
### Improperly formatted issue
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
|
|||
const $submitButton = $form.find('input[type=submit], button[type=submit]');
|
||||
|
||||
if (!$submitButton.attr('disabled')) {
|
||||
$submitButton.trigger('click', [e]);
|
||||
$submitButton.disable();
|
||||
$form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ $(() => {
|
|||
issueLinkBase: $boardApp.dataset.issueLinkBase,
|
||||
rootPath: $boardApp.dataset.rootPath,
|
||||
bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
|
||||
detailIssue: Store.detail
|
||||
detailIssue: Store.detail,
|
||||
defaultAvatar: $boardApp.dataset.defaultAvatar,
|
||||
},
|
||||
computed: {
|
||||
detailIssueVisible () {
|
||||
|
|
@ -82,7 +83,7 @@ $(() => {
|
|||
gl.boardService.all()
|
||||
.then((resp) => {
|
||||
resp.json().forEach((board) => {
|
||||
const list = Store.addList(board);
|
||||
const list = Store.addList(board, this.defaultAvatar);
|
||||
|
||||
if (list.type === 'closed') {
|
||||
list.position = Infinity;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<<<<<<< HEAD:app/assets/javascripts/boards/models/assignee.js
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
class ListAssignee {
|
||||
|
|
@ -6,6 +7,14 @@ class ListAssignee {
|
|||
this.name = user.name;
|
||||
this.username = user.username;
|
||||
this.avatarUrl = user.avatar_url;
|
||||
=======
|
||||
class ListUser {
|
||||
constructor(user, defaultAvatar) {
|
||||
this.id = user.id;
|
||||
this.name = user.name;
|
||||
this.username = user.username;
|
||||
this.avatar = user.avatar_url || defaultAvatar;
|
||||
>>>>>>> 10c1bf2d77fd0ab21309d0b136cbc0ac11f56c77:app/assets/javascripts/boards/models/user.js
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
class ListIssue {
|
||||
constructor (obj) {
|
||||
constructor (obj, defaultAvatar) {
|
||||
this.globalId = obj.id;
|
||||
this.id = obj.iid;
|
||||
this.title = obj.title;
|
||||
|
|
@ -18,6 +18,13 @@ class ListIssue {
|
|||
this.selected = false;
|
||||
this.position = obj.relative_position || Infinity;
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
if (obj.assignee) {
|
||||
this.assignee = new ListUser(obj.assignee, defaultAvatar);
|
||||
}
|
||||
|
||||
>>>>>>> 10c1bf2d77fd0ab21309d0b136cbc0ac11f56c77
|
||||
if (obj.milestone) {
|
||||
this.milestone = new ListMilestone(obj.milestone);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import queryData from '../utils/query_data';
|
|||
const PER_PAGE = 20;
|
||||
|
||||
class List {
|
||||
constructor (obj) {
|
||||
constructor (obj, defaultAvatar) {
|
||||
this.id = obj.id;
|
||||
this._uid = this.guid();
|
||||
this.position = obj.position;
|
||||
|
|
@ -18,6 +18,7 @@ class List {
|
|||
this.loadingMore = false;
|
||||
this.issues = [];
|
||||
this.issuesSize = 0;
|
||||
this.defaultAvatar = defaultAvatar;
|
||||
|
||||
if (obj.label) {
|
||||
this.label = new ListLabel(obj.label);
|
||||
|
|
@ -106,7 +107,7 @@ class List {
|
|||
|
||||
createIssues (data) {
|
||||
data.forEach((issueObj) => {
|
||||
this.addIssue(new ListIssue(issueObj));
|
||||
this.addIssue(new ListIssue(issueObj, this.defaultAvatar));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ gl.issueBoards.BoardsStore = {
|
|||
this.state.lists = [];
|
||||
this.filter.path = gl.utils.getUrlParamsArray().join('&');
|
||||
},
|
||||
addList (listObj) {
|
||||
const list = new List(listObj);
|
||||
addList (listObj, defaultAvatar) {
|
||||
const list = new List(listObj, defaultAvatar);
|
||||
this.state.lists.push(list);
|
||||
|
||||
return list;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export default Vue.component('pipelines-table', {
|
|||
isLoading: false,
|
||||
hasError: false,
|
||||
isMakingRequest: false,
|
||||
updateGraphDropdown: false,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -130,15 +131,21 @@ export default Vue.component('pipelines-table', {
|
|||
const pipelines = response.pipelines || response;
|
||||
this.store.storePipelines(pipelines);
|
||||
this.isLoading = false;
|
||||
this.updateGraphDropdown = true;
|
||||
},
|
||||
|
||||
errorCallback() {
|
||||
this.hasError = true;
|
||||
this.isLoading = false;
|
||||
this.updateGraphDropdown = false;
|
||||
},
|
||||
|
||||
setIsMakingRequest(isMakingRequest) {
|
||||
this.isMakingRequest = isMakingRequest;
|
||||
|
||||
if (isMakingRequest) {
|
||||
this.updateGraphDropdown = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -163,7 +170,9 @@ export default Vue.component('pipelines-table', {
|
|||
v-if="shouldRenderTable">
|
||||
<pipelines-table-component
|
||||
:pipelines="state.pipelines"
|
||||
:service="service" />
|
||||
:service="service"
|
||||
:update-graph-dropdown="updateGraphDropdown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ export default {
|
|||
<span v-if="count === 50" class="events-info pull-right">
|
||||
<i class="fa fa-warning has-tooltip"
|
||||
aria-hidden="true"
|
||||
title="Limited to showing 50 events at most"
|
||||
:title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)"
|
||||
data-placement="top"></i>
|
||||
Showing 50 events
|
||||
{{ n__('Showing %d event', 'Showing %d events', 50) }}
|
||||
</span>
|
||||
`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
|
|||
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
|
||||
·
|
||||
<span>
|
||||
Opened
|
||||
{{ __('OpenedNDaysAgo|Opened') }}
|
||||
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
|
||||
</span>
|
||||
<span>
|
||||
by
|
||||
{{ __('ByAuthor|by') }}
|
||||
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
|
|||
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
|
||||
·
|
||||
<span>
|
||||
Opened
|
||||
{{ __('OpenedNDaysAgo|Opened') }}
|
||||
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
|
||||
</span>
|
||||
<span>
|
||||
by
|
||||
{{ __('ByAuthor|by') }}
|
||||
<a :href="issue.author.webUrl" class="issue-author-link">
|
||||
{{ issue.author.name }}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
|
|||
</a>
|
||||
</h5>
|
||||
<span>
|
||||
First
|
||||
{{ __('FirstPushedBy|First') }}
|
||||
<span class="commit-icon">${iconCommit}</span>
|
||||
<a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a>
|
||||
pushed by
|
||||
{{ __('FirstPushedBy|pushed by') }}
|
||||
<a :href="commit.author.webUrl" class="commit-author-link">
|
||||
{{ commit.author.name }}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
|
|||
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
|
||||
·
|
||||
<span>
|
||||
Opened
|
||||
{{ __('OpenedNDaysAgo|Opened') }}
|
||||
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
|
||||
</span>
|
||||
<span>
|
||||
by
|
||||
{{ __('ByAuthor|by') }}
|
||||
<a :href="issue.author.webUrl" class="issue-author-link">
|
||||
{{ issue.author.name }}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -28,11 +28,11 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
|
|||
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
|
||||
·
|
||||
<span>
|
||||
Opened
|
||||
{{ __('OpenedNDaysAgo|Opened') }}
|
||||
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
|
||||
</span>
|
||||
<span>
|
||||
by
|
||||
{{ __('ByAuthor|by') }}
|
||||
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
|
||||
</span>
|
||||
<template v-if="mergeRequest.state === 'closed'">
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
|
|||
</h5>
|
||||
<span>
|
||||
<a :href="build.url" class="build-date">{{ build.date }}</a>
|
||||
by
|
||||
{{ __('ByAuthor|by') }}
|
||||
<a :href="build.author.webUrl" class="issue-author-link">
|
||||
{{ build.author.name }}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ global.cycleAnalytics.TotalTimeComponent = Vue.extend({
|
|||
template: `
|
||||
<span class="total-time">
|
||||
<template v-if="Object.keys(time).length">
|
||||
<template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template>
|
||||
<template v-if="time.hours">{{ time.hours }} <span>hr</span></template>
|
||||
<template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template>
|
||||
<template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template>
|
||||
<template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template>
|
||||
<template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template>
|
||||
<template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template>
|
||||
<template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template>
|
||||
</template>
|
||||
<template v-else>
|
||||
--
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import Vue from 'vue';
|
||||
import Cookies from 'js-cookie';
|
||||
import Translate from '../vue_shared/translate';
|
||||
import LimitWarningComponent from './components/limit_warning_component';
|
||||
|
||||
require('./components/stage_code_component');
|
||||
|
|
@ -16,6 +17,8 @@ require('./cycle_analytics_service');
|
|||
require('./cycle_analytics_store');
|
||||
require('./default_event_objects');
|
||||
|
||||
Vue.use(Translate);
|
||||
|
||||
$(() => {
|
||||
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
|
||||
const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class CycleAnalyticsService {
|
|||
startDate,
|
||||
} = options;
|
||||
|
||||
return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, {
|
||||
return $.get(`${this.requestPath}/events/${stage.name}.json`, {
|
||||
cycle_analytics: {
|
||||
start_date: startDate,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
import { __ } from '../locale';
|
||||
|
||||
require('../lib/utils/text_utility');
|
||||
const DEFAULT_EVENT_OBJECTS = require('./default_event_objects');
|
||||
|
|
@ -7,13 +8,13 @@ const global = window.gl || (window.gl = {});
|
|||
global.cycleAnalytics = global.cycleAnalytics || {};
|
||||
|
||||
const EMPTY_STAGE_TEXTS = {
|
||||
issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
|
||||
plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
|
||||
code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
|
||||
test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
|
||||
review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
|
||||
staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
|
||||
production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
|
||||
issue: __('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'),
|
||||
plan: __('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'),
|
||||
code: __('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'),
|
||||
test: __('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'),
|
||||
review: __('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'),
|
||||
staging: __('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'),
|
||||
production: __('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'),
|
||||
};
|
||||
|
||||
global.cycleAnalytics.CycleAnalyticsStore = {
|
||||
|
|
@ -38,7 +39,7 @@ global.cycleAnalytics.CycleAnalyticsStore = {
|
|||
});
|
||||
|
||||
newData.stages.forEach((item) => {
|
||||
const stageSlug = gl.text.dasherize(item.title.toLowerCase());
|
||||
const stageSlug = gl.text.dasherize(item.name.toLowerCase());
|
||||
item.active = false;
|
||||
item.isUserAllowed = data.permissions[stageSlug];
|
||||
item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
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 }, {});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
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';
|
|||
import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
|
||||
import ShortcutsWiki from './shortcuts_wiki';
|
||||
import BlobViewer from './blob/viewer/index';
|
||||
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
|
||||
|
||||
const ShortcutsBlob = require('./shortcuts_blob');
|
||||
|
||||
|
|
@ -198,6 +199,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
|
|||
new LabelsSelect();
|
||||
new MilestoneSelect();
|
||||
new gl.IssuableTemplateSelectors();
|
||||
new AutoWidthDropdownSelect($('.js-target-branch-select')).init();
|
||||
break;
|
||||
case 'projects:tags:new':
|
||||
new ZenMode();
|
||||
|
|
@ -344,6 +346,9 @@ const ShortcutsBlob = require('./shortcuts_blob');
|
|||
case 'projects:artifacts:browse':
|
||||
new BuildArtifacts();
|
||||
break;
|
||||
case 'projects:artifacts:file':
|
||||
new BlobViewer();
|
||||
break;
|
||||
case 'help:index':
|
||||
gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge'));
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ require('./preview_markdown');
|
|||
|
||||
window.DropzoneInput = (function() {
|
||||
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;
|
||||
alertClass = "alert alert-danger alert-dismissable div-dropzone-alert";
|
||||
alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\"";
|
||||
|
|
@ -16,7 +16,7 @@ window.DropzoneInput = (function() {
|
|||
iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>";
|
||||
uploadProgress = $("<div class=\"div-dropzone-progress\"></div>");
|
||||
btnAlert = "<button type=\"button\"" + alertAttr + ">×</button>";
|
||||
project_uploads_path = window.project_uploads_path || null;
|
||||
uploads_path = window.uploads_path || null;
|
||||
max_file_size = gon.max_file_size || 10;
|
||||
form_textarea = $(form).find(".js-gfm-input");
|
||||
form_textarea.wrap("<div class=\"div-dropzone\"></div>");
|
||||
|
|
@ -39,10 +39,10 @@ window.DropzoneInput = (function() {
|
|||
"display": "none"
|
||||
});
|
||||
|
||||
if (!project_uploads_path) return;
|
||||
if (!uploads_path) return;
|
||||
|
||||
dropzone = form_dropzone.dropzone({
|
||||
url: project_uploads_path,
|
||||
url: uploads_path,
|
||||
dictDefaultMessage: "",
|
||||
clickable: true,
|
||||
paramName: "file",
|
||||
|
|
@ -159,7 +159,7 @@ window.DropzoneInput = (function() {
|
|||
formData = new FormData();
|
||||
formData.append("file", item, filename);
|
||||
return $.ajax({
|
||||
url: project_uploads_path,
|
||||
url: uploads_path,
|
||||
type: "POST",
|
||||
data: formData,
|
||||
dataType: "json",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
<script>
|
||||
|
||||
/* eslint-disable no-new */
|
||||
/* global Flash */
|
||||
import EnvironmentsService from '../services/environments_service';
|
||||
import EnvironmentTable from './environments_table.vue';
|
||||
|
|
@ -71,11 +69,13 @@ export default {
|
|||
|
||||
eventHub.$on('refreshEnvironments', this.fetchEnvironments);
|
||||
eventHub.$on('toggleFolder', this.toggleFolder);
|
||||
eventHub.$on('postAction', this.postAction);
|
||||
},
|
||||
|
||||
beforeDestroyed() {
|
||||
eventHub.$off('refreshEnvironments');
|
||||
eventHub.$off('toggleFolder');
|
||||
eventHub.$off('postAction');
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
@ -122,6 +122,7 @@ export default {
|
|||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash('An error occurred while fetching the environments.');
|
||||
});
|
||||
},
|
||||
|
|
@ -137,9 +138,16 @@ export default {
|
|||
})
|
||||
.catch(() => {
|
||||
this.isLoadingFolderContent = false;
|
||||
// eslint-disable-next-line no-new
|
||||
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>
|
||||
|
|
@ -217,7 +225,6 @@ export default {
|
|||
:environments="state.environments"
|
||||
:can-create-deployment="canCreateDeploymentParsed"
|
||||
:can-read-environment="canReadEnvironmentParsed"
|
||||
:service="service"
|
||||
:is-loading-folder-content="isLoadingFolderContent" />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
<script>
|
||||
/* global Flash */
|
||||
/* eslint-disable no-new */
|
||||
|
||||
import playIconSvg from 'icons/_icon_play.svg';
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
|
|
@ -12,11 +9,6 @@ export default {
|
|||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
@ -38,15 +30,7 @@ export default {
|
|||
|
||||
$(this.$refs.tooltip).tooltip('destroy');
|
||||
|
||||
this.service.postAction(endpoint)
|
||||
.then(() => {
|
||||
this.isLoading = false;
|
||||
eventHub.$emit('refreshEnvironments');
|
||||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
new Flash('An error occured while making the request.');
|
||||
});
|
||||
eventHub.$emit('postAction', endpoint);
|
||||
},
|
||||
|
||||
isActionDisabled(action) {
|
||||
|
|
|
|||
|
|
@ -46,11 +46,6 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
|
@ -543,31 +538,34 @@ export default {
|
|||
|
||||
<actions-component
|
||||
v-if="hasManualActions && canCreateDeployment"
|
||||
:service="service"
|
||||
:actions="manualActions"/>
|
||||
:actions="manualActions"
|
||||
/>
|
||||
|
||||
<external-url-component
|
||||
v-if="externalURL && canReadEnvironment"
|
||||
:external-url="externalURL"/>
|
||||
:external-url="externalURL"
|
||||
/>
|
||||
|
||||
<monitoring-button-component
|
||||
v-if="monitoringUrl && canReadEnvironment"
|
||||
:monitoring-url="monitoringUrl"/>
|
||||
:monitoring-url="monitoringUrl"
|
||||
/>
|
||||
|
||||
<terminal-button-component
|
||||
v-if="model && model.terminal_path"
|
||||
:terminal-path="model.terminal_path"/>
|
||||
:terminal-path="model.terminal_path"
|
||||
/>
|
||||
|
||||
<stop-component
|
||||
v-if="hasStopAction && canCreateDeployment"
|
||||
:stop-url="model.stop_path"
|
||||
:service="service"/>
|
||||
/>
|
||||
|
||||
<rollback-component
|
||||
v-if="canRetry && canCreateDeployment"
|
||||
:is-last-deployment="isLastDeployment"
|
||||
:retry-url="retryUrl"
|
||||
:service="service"/>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
<script>
|
||||
/* global Flash */
|
||||
/* eslint-disable no-new */
|
||||
/**
|
||||
* Renders Rollback or Re deploy button in environments table depending
|
||||
* of the provided property `isLastDeployment`.
|
||||
|
|
@ -20,11 +18,6 @@ export default {
|
|||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
@ -37,17 +30,7 @@ export default {
|
|||
onClick() {
|
||||
this.isLoading = true;
|
||||
|
||||
$(this.$el).tooltip('destroy');
|
||||
|
||||
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.');
|
||||
});
|
||||
eventHub.$emit('postAction', this.retryUrl);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
<script>
|
||||
/* global Flash */
|
||||
/* eslint-disable no-new, no-alert */
|
||||
/**
|
||||
* Renders the stop "button" that allows stop an environment.
|
||||
* Used in environments table.
|
||||
|
|
@ -13,11 +11,6 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
@ -34,20 +27,13 @@ export default {
|
|||
|
||||
methods: {
|
||||
onClick() {
|
||||
// eslint-disable-next-line no-alert
|
||||
if (confirm('Are you sure you want to stop this environment?')) {
|
||||
this.isLoading = true;
|
||||
|
||||
$(this.$el).tooltip('destroy');
|
||||
|
||||
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.', 'alert');
|
||||
});
|
||||
eventHub.$emit('postAction', this.stopUrl);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,11 +28,6 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
isLoadingFolderContent: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
|
@ -78,7 +73,7 @@ export default {
|
|||
:model="model"
|
||||
:can-create-deployment="canCreateDeployment"
|
||||
:can-read-environment="canReadEnvironment"
|
||||
:service="service" />
|
||||
/>
|
||||
|
||||
<template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
|
||||
<tr v-if="isLoadingFolderContent">
|
||||
|
|
@ -96,7 +91,7 @@ export default {
|
|||
:model="children"
|
||||
:can-create-deployment="canCreateDeployment"
|
||||
:can-read-environment="canReadEnvironment"
|
||||
:service="service" />
|
||||
/>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script>
|
||||
/* eslint-disable no-new */
|
||||
/* global Flash */
|
||||
import EnvironmentsService from '../services/environments_service';
|
||||
import EnvironmentTable from '../components/environments_table.vue';
|
||||
|
|
@ -99,6 +98,7 @@ export default {
|
|||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash('An error occurred while fetching the environments.', 'alert');
|
||||
});
|
||||
},
|
||||
|
|
@ -169,7 +169,7 @@ export default {
|
|||
:environments="state.environments"
|
||||
:can-create-deployment="canCreateDeploymentParsed"
|
||||
:can-read-environment="canReadEnvironmentParsed"
|
||||
:service="service"/>
|
||||
/>
|
||||
|
||||
<table-pagination
|
||||
v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
this.input = input || $('.js-gfm-input');
|
||||
this.enableMap = enableMap;
|
||||
this.setupLifecycle();
|
||||
},
|
||||
setupLifecycle() {
|
||||
|
|
@ -115,189 +123,15 @@ window.gl.GfmAutoComplete = {
|
|||
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
|
||||
});
|
||||
},
|
||||
|
||||
setupAtWho: function($input) {
|
||||
// Emoji
|
||||
$input.atwho({
|
||||
at: ':',
|
||||
displayTpl: function(value) {
|
||||
return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template;
|
||||
}.bind(this),
|
||||
insertTpl: ':${name}:',
|
||||
skipSpecialCharacterTest: true,
|
||||
data: this.defaultLoadingData,
|
||||
callbacks: {
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
filter: this.DefaultOptions.filter,
|
||||
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);
|
||||
|
||||
matcher: (flag, subtext) => {
|
||||
const relevantText = subtext.trim().split(/\s/).pop();
|
||||
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
|
||||
const match = regexp.exec(relevantText);
|
||||
|
||||
return match && match.length ? match[1] : null;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Team Members
|
||||
$input.atwho({
|
||||
at: '@',
|
||||
displayTpl: function(value) {
|
||||
return value.username != null ? this.Members.template : this.Loading.template;
|
||||
}.bind(this),
|
||||
insertTpl: '${atwho-at}${username}',
|
||||
searchKey: 'search',
|
||||
alwaysHighlightFirst: true,
|
||||
skipSpecialCharacterTest: true,
|
||||
data: this.defaultLoadingData,
|
||||
callbacks: {
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
filter: this.DefaultOptions.filter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
beforeSave: function(members) {
|
||||
return $.map(members, function(m) {
|
||||
let title = '';
|
||||
if (m.username == null) {
|
||||
return m;
|
||||
}
|
||||
title = m.name;
|
||||
if (m.count) {
|
||||
title += " (" + m.count + ")";
|
||||
}
|
||||
|
||||
const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
|
||||
const imgAvatar = `<img src="${m.avatar_url}" alt="${m.username}" class="avatar avatar-inline center s26"/>`;
|
||||
const txtAvatar = `<div class="avatar center avatar-inline s26">${autoCompleteAvatar}</div>`;
|
||||
|
||||
return {
|
||||
username: m.username,
|
||||
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
|
||||
title: sanitize(title),
|
||||
search: sanitize(m.username + " " + m.name)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
$input.atwho({
|
||||
at: '#',
|
||||
alias: 'issues',
|
||||
searchKey: 'search',
|
||||
displayTpl: function(value) {
|
||||
return value.title != null ? this.Issues.template : this.Loading.template;
|
||||
}.bind(this),
|
||||
data: this.defaultLoadingData,
|
||||
insertTpl: '${atwho-at}${id}',
|
||||
callbacks: {
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
filter: this.DefaultOptions.filter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
beforeSave: function(issues) {
|
||||
return $.map(issues, function(i) {
|
||||
if (i.title == null) {
|
||||
return i;
|
||||
}
|
||||
return {
|
||||
id: i.iid,
|
||||
title: sanitize(i.title),
|
||||
search: i.iid + " " + i.title
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
$input.atwho({
|
||||
at: '%',
|
||||
alias: 'milestones',
|
||||
searchKey: 'search',
|
||||
insertTpl: '${atwho-at}${title}',
|
||||
displayTpl: function(value) {
|
||||
return value.title != null ? this.Milestones.template : this.Loading.template;
|
||||
}.bind(this),
|
||||
data: this.defaultLoadingData,
|
||||
callbacks: {
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
filter: this.DefaultOptions.filter,
|
||||
beforeSave: function(milestones) {
|
||||
return $.map(milestones, function(m) {
|
||||
if (m.title == null) {
|
||||
return m;
|
||||
}
|
||||
return {
|
||||
id: m.iid,
|
||||
title: sanitize(m.title),
|
||||
search: "" + m.title
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
$input.atwho({
|
||||
at: '!',
|
||||
alias: 'mergerequests',
|
||||
searchKey: 'search',
|
||||
displayTpl: function(value) {
|
||||
return value.title != null ? this.Issues.template : this.Loading.template;
|
||||
}.bind(this),
|
||||
data: this.defaultLoadingData,
|
||||
insertTpl: '${atwho-at}${id}',
|
||||
callbacks: {
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
filter: this.DefaultOptions.filter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
beforeSave: function(merges) {
|
||||
return $.map(merges, function(m) {
|
||||
if (m.title == null) {
|
||||
return m;
|
||||
}
|
||||
return {
|
||||
id: m.iid,
|
||||
title: sanitize(m.title),
|
||||
search: m.iid + " " + m.title
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
$input.atwho({
|
||||
at: '~',
|
||||
alias: 'labels',
|
||||
searchKey: 'search',
|
||||
data: this.defaultLoadingData,
|
||||
displayTpl: function(value) {
|
||||
return this.isLoading(value) ? this.Loading.template : this.Labels.template;
|
||||
}.bind(this),
|
||||
insertTpl: '${atwho-at}${title}',
|
||||
callbacks: {
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
filter: this.DefaultOptions.filter,
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
beforeSave: function(merges) {
|
||||
if (gl.GfmAutoComplete.isLoading(merges)) return merges;
|
||||
var sanitizeLabelTitle;
|
||||
sanitizeLabelTitle = function(title) {
|
||||
if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) {
|
||||
return "\"" + (sanitize(title)) + "\"";
|
||||
} else {
|
||||
return sanitize(title);
|
||||
}
|
||||
};
|
||||
return $.map(merges, function(m) {
|
||||
return {
|
||||
title: sanitize(m.title),
|
||||
color: m.color,
|
||||
search: "" + m.title
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
// We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
|
||||
$input.filter('[data-supports-slash-commands="true"]').atwho({
|
||||
at: '/',
|
||||
|
|
@ -365,6 +199,207 @@ window.gl.GfmAutoComplete = {
|
|||
});
|
||||
return;
|
||||
},
|
||||
|
||||
setupEmoji($input) {
|
||||
// Emoji
|
||||
$input.atwho({
|
||||
at: ':',
|
||||
displayTpl: function(value) {
|
||||
return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template;
|
||||
}.bind(this),
|
||||
insertTpl: ':${name}:',
|
||||
skipSpecialCharacterTest: true,
|
||||
data: this.defaultLoadingData,
|
||||
callbacks: {
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
filter: this.DefaultOptions.filter,
|
||||
|
||||
matcher: (flag, subtext) => {
|
||||
const relevantText = subtext.trim().split(/\s/).pop();
|
||||
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
|
||||
const match = regexp.exec(relevantText);
|
||||
|
||||
return match && match.length ? match[1] : null;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setupMembers($input) {
|
||||
// Team Members
|
||||
$input.atwho({
|
||||
at: '@',
|
||||
displayTpl: function(value) {
|
||||
return value.username != null ? this.Members.template : this.Loading.template;
|
||||
}.bind(this),
|
||||
insertTpl: '${atwho-at}${username}',
|
||||
searchKey: 'search',
|
||||
alwaysHighlightFirst: true,
|
||||
skipSpecialCharacterTest: true,
|
||||
data: this.defaultLoadingData,
|
||||
callbacks: {
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
filter: this.DefaultOptions.filter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
beforeSave: function(members) {
|
||||
return $.map(members, function(m) {
|
||||
let title = '';
|
||||
if (m.username == null) {
|
||||
return m;
|
||||
}
|
||||
title = m.name;
|
||||
if (m.count) {
|
||||
title += " (" + m.count + ")";
|
||||
}
|
||||
|
||||
const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
|
||||
const imgAvatar = `<img src="${m.avatar_url}" alt="${m.username}" class="avatar avatar-inline center s26"/>`;
|
||||
const txtAvatar = `<div class="avatar center avatar-inline s26">${autoCompleteAvatar}</div>`;
|
||||
|
||||
return {
|
||||
username: m.username,
|
||||
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
|
||||
title: sanitize(title),
|
||||
search: sanitize(m.username + " " + m.name)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setupIssues($input) {
|
||||
$input.atwho({
|
||||
at: '#',
|
||||
alias: 'issues',
|
||||
searchKey: 'search',
|
||||
displayTpl: function(value) {
|
||||
return value.title != null ? this.Issues.template : this.Loading.template;
|
||||
}.bind(this),
|
||||
data: this.defaultLoadingData,
|
||||
insertTpl: '${atwho-at}${id}',
|
||||
callbacks: {
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
filter: this.DefaultOptions.filter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
beforeSave: function(issues) {
|
||||
return $.map(issues, function(i) {
|
||||
if (i.title == null) {
|
||||
return i;
|
||||
}
|
||||
return {
|
||||
id: i.iid,
|
||||
title: sanitize(i.title),
|
||||
search: i.iid + " " + i.title
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setupMilestones($input) {
|
||||
$input.atwho({
|
||||
at: '%',
|
||||
alias: 'milestones',
|
||||
searchKey: 'search',
|
||||
insertTpl: '${atwho-at}${title}',
|
||||
displayTpl: function(value) {
|
||||
return value.title != null ? this.Milestones.template : this.Loading.template;
|
||||
}.bind(this),
|
||||
data: this.defaultLoadingData,
|
||||
callbacks: {
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
filter: this.DefaultOptions.filter,
|
||||
beforeSave: function(milestones) {
|
||||
return $.map(milestones, function(m) {
|
||||
if (m.title == null) {
|
||||
return m;
|
||||
}
|
||||
return {
|
||||
id: m.iid,
|
||||
title: sanitize(m.title),
|
||||
search: "" + m.title
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setupMergeRequests($input) {
|
||||
$input.atwho({
|
||||
at: '!',
|
||||
alias: 'mergerequests',
|
||||
searchKey: 'search',
|
||||
displayTpl: function(value) {
|
||||
return value.title != null ? this.Issues.template : this.Loading.template;
|
||||
}.bind(this),
|
||||
data: this.defaultLoadingData,
|
||||
insertTpl: '${atwho-at}${id}',
|
||||
callbacks: {
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
filter: this.DefaultOptions.filter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
beforeSave: function(merges) {
|
||||
return $.map(merges, function(m) {
|
||||
if (m.title == null) {
|
||||
return m;
|
||||
}
|
||||
return {
|
||||
id: m.iid,
|
||||
title: sanitize(m.title),
|
||||
search: m.iid + " " + m.title
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setupLabels($input) {
|
||||
$input.atwho({
|
||||
at: '~',
|
||||
alias: 'labels',
|
||||
searchKey: 'search',
|
||||
data: this.defaultLoadingData,
|
||||
displayTpl: function(value) {
|
||||
return this.isLoading(value) ? this.Loading.template : this.Labels.template;
|
||||
}.bind(this),
|
||||
insertTpl: '${atwho-at}${title}',
|
||||
callbacks: {
|
||||
matcher: this.DefaultOptions.matcher,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert,
|
||||
filter: this.DefaultOptions.filter,
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
beforeSave: function(merges) {
|
||||
if (gl.GfmAutoComplete.isLoading(merges)) return merges;
|
||||
var sanitizeLabelTitle;
|
||||
sanitizeLabelTitle = function(title) {
|
||||
if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) {
|
||||
return "\"" + (sanitize(title)) + "\"";
|
||||
} else {
|
||||
return sanitize(title);
|
||||
}
|
||||
};
|
||||
return $.map(merges, function(m) {
|
||||
return {
|
||||
title: sanitize(m.title),
|
||||
color: m.color,
|
||||
search: "" + m.title
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
fetchData: function($input, at) {
|
||||
if (this.isLoadingData[at]) return;
|
||||
this.isLoadingData[at] = true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
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;
|
||||
|
|
@ -35,6 +35,14 @@
|
|||
});
|
||||
};
|
||||
|
||||
w.gl.utils.ajaxPost = function(url, data) {
|
||||
return $.ajax({
|
||||
type: 'POST',
|
||||
url: url,
|
||||
data: data,
|
||||
});
|
||||
};
|
||||
|
||||
w.gl.utils.extractLast = function(term) {
|
||||
return this.split(term).pop();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:37-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}};
|
||||
|
|
@ -0,0 +1 @@
|
|||
var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}};
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,70 @@
|
|||
import Jed from 'jed';
|
||||
|
||||
/**
|
||||
This is required to require all the translation folders in the current directory
|
||||
this saves us having to do this manually & keep up to date with new languages
|
||||
**/
|
||||
function requireAll(requireContext) { return requireContext.keys().map(requireContext); }
|
||||
|
||||
const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/));
|
||||
const locales = allLocales.reduce((d, obj) => {
|
||||
const data = d;
|
||||
const localeKey = Object.keys(obj)[0];
|
||||
|
||||
data[localeKey] = obj[localeKey];
|
||||
|
||||
return data;
|
||||
}, {});
|
||||
|
||||
let lang = document.querySelector('html').getAttribute('lang') || 'en';
|
||||
lang = lang.replace(/-/g, '_');
|
||||
|
||||
const locale = new Jed(locales[lang]);
|
||||
|
||||
/**
|
||||
Translates `text`
|
||||
|
||||
@param text The text to be translated
|
||||
@returns {String} The translated text
|
||||
**/
|
||||
const gettext = locale.gettext.bind(locale);
|
||||
|
||||
/**
|
||||
Translate the text with a number
|
||||
if the number is more than 1 it will use the `pluralText` translation.
|
||||
This method allows for contexts, see below re. contexts
|
||||
|
||||
@param text Singular text to translate (eg. '%d day')
|
||||
@param pluralText Plural text to translate (eg. '%d days')
|
||||
@param count Number to decide which translation to use (eg. 2)
|
||||
@returns {String} Translated text with the number replaced (eg. '2 days')
|
||||
**/
|
||||
const ngettext = (text, pluralText, count) => {
|
||||
const translated = locale.ngettext(text, pluralText, count).replace(/%d/g, count).split('|');
|
||||
|
||||
return translated[translated.length - 1];
|
||||
};
|
||||
|
||||
/**
|
||||
Translate context based text
|
||||
Either pass in the context translation like `Context|Text to translate`
|
||||
or allow for dynamic text by doing passing in the context first & then the text to translate
|
||||
|
||||
@param keyOrContext Can be either the key to translate including the context
|
||||
(eg. 'Context|Text') or just the context for the translation
|
||||
(eg. 'Context')
|
||||
@param key Is the dynamic variable you want to be translated
|
||||
@returns {String} Translated context based text
|
||||
**/
|
||||
const pgettext = (keyOrContext, key) => {
|
||||
const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext;
|
||||
const translated = gettext(normalizedKey).split('|');
|
||||
|
||||
return translated[translated.length - 1];
|
||||
};
|
||||
|
||||
export { lang };
|
||||
export { gettext as __ };
|
||||
export { ngettext as n__ };
|
||||
export { pgettext as s__ };
|
||||
export default locale;
|
||||
|
|
@ -291,7 +291,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
|
|||
|
||||
MergeRequestWidget.prototype.updateCommitUrls = function(id) {
|
||||
const commitsUrl = this.opts.commits_path;
|
||||
$('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/'));
|
||||
$('.js-commit-link').text(id).attr('href', [commitsUrl, id].join('/'));
|
||||
};
|
||||
|
||||
MergeRequestWidget.prototype.initMiniPipelineGraph = function() {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
filterByText: true,
|
||||
remote: false,
|
||||
fieldName: $branchSelect.data('field-name'),
|
||||
filterInput: 'input[type="search"]',
|
||||
selectable: true,
|
||||
isSelectable: function(branch, $el) {
|
||||
return !$el.hasClass('is-active');
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -26,12 +26,13 @@ const normalizeNewlines = function(str) {
|
|||
|
||||
this.Notes = (function() {
|
||||
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
|
||||
const REGEX_SLASH_COMMANDS = /\/\w+/g;
|
||||
|
||||
Notes.interval = null;
|
||||
|
||||
function Notes(notes_url, note_ids, last_fetched_at, view) {
|
||||
this.updateTargetButtons = bind(this.updateTargetButtons, this);
|
||||
this.updateCloseButton = bind(this.updateCloseButton, this);
|
||||
this.updateComment = bind(this.updateComment, this);
|
||||
this.visibilityChange = bind(this.visibilityChange, this);
|
||||
this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this);
|
||||
this.addDiffNote = bind(this.addDiffNote, this);
|
||||
|
|
@ -47,6 +48,7 @@ const normalizeNewlines = function(str) {
|
|||
this.refresh = bind(this.refresh, this);
|
||||
this.keydownNoteText = bind(this.keydownNoteText, this);
|
||||
this.toggleCommitList = bind(this.toggleCommitList, this);
|
||||
this.postComment = bind(this.postComment, this);
|
||||
|
||||
this.notes_url = notes_url;
|
||||
this.note_ids = note_ids;
|
||||
|
|
@ -82,28 +84,19 @@ const normalizeNewlines = function(str) {
|
|||
};
|
||||
|
||||
Notes.prototype.addBinding = function() {
|
||||
// add note to UI after creation
|
||||
$(document).on("ajax:success", ".js-main-target-form", this.addNote);
|
||||
$(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote);
|
||||
// catch note ajax errors
|
||||
$(document).on("ajax:error", ".js-main-target-form", this.addNoteError);
|
||||
// change note in UI after update
|
||||
$(document).on("ajax:success", "form.edit-note", this.updateNote);
|
||||
// Edit note link
|
||||
$(document).on("click", ".js-note-edit", this.showEditForm.bind(this));
|
||||
$(document).on("click", ".note-edit-cancel", this.cancelEdit);
|
||||
// Reopen and close actions for Issue/MR combined with note form submit
|
||||
$(document).on("click", ".js-comment-button", this.updateCloseButton);
|
||||
$(document).on("click", ".js-comment-submit-button", this.postComment);
|
||||
$(document).on("click", ".js-comment-save-button", this.updateComment);
|
||||
$(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
|
||||
// resolve a discussion
|
||||
$(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion);
|
||||
$(document).on('click', '.js-comment-resolve-button', this.postComment);
|
||||
// remove a note (in general)
|
||||
$(document).on("click", ".js-note-delete", this.removeNote);
|
||||
// delete note attachment
|
||||
$(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
|
||||
// reset main target form after submit
|
||||
$(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
|
||||
$(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm);
|
||||
// reset main target form when clicking discard
|
||||
$(document).on("click", ".js-note-discard", this.resetMainTargetForm);
|
||||
// update the file name when an attachment is selected
|
||||
|
|
@ -120,20 +113,20 @@ const normalizeNewlines = function(str) {
|
|||
$(document).on("visibilitychange", this.visibilityChange);
|
||||
// when issue status changes, we need to refresh data
|
||||
$(document).on("issuable:change", this.refresh);
|
||||
// ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
|
||||
$(document).on("ajax:success", ".js-main-target-form", this.addNote);
|
||||
$(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote);
|
||||
$(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm);
|
||||
$(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
|
||||
// when a key is clicked on the notes
|
||||
return $(document).on("keydown", ".js-note-text", this.keydownNoteText);
|
||||
};
|
||||
|
||||
Notes.prototype.cleanBinding = function() {
|
||||
$(document).off("ajax:success", ".js-main-target-form");
|
||||
$(document).off("ajax:success", ".js-discussion-note-form");
|
||||
$(document).off("ajax:success", "form.edit-note");
|
||||
$(document).off("click", ".js-note-edit");
|
||||
$(document).off("click", ".note-edit-cancel");
|
||||
$(document).off("click", ".js-note-delete");
|
||||
$(document).off("click", ".js-note-attachment-delete");
|
||||
$(document).off("ajax:complete", ".js-main-target-form");
|
||||
$(document).off("ajax:success", ".js-main-target-form");
|
||||
$(document).off("click", ".js-discussion-reply-button");
|
||||
$(document).off("click", ".js-add-diff-note-button");
|
||||
$(document).off("visibilitychange");
|
||||
|
|
@ -144,6 +137,9 @@ const normalizeNewlines = function(str) {
|
|||
$(document).off("keydown", ".js-note-text");
|
||||
$(document).off('click', '.js-comment-resolve-button');
|
||||
$(document).off("click", '.system-note-commit-list-toggler');
|
||||
$(document).off("ajax:success", ".js-main-target-form");
|
||||
$(document).off("ajax:success", ".js-discussion-note-form");
|
||||
$(document).off("ajax:complete", ".js-main-target-form");
|
||||
};
|
||||
|
||||
Notes.initCommentTypeToggle = function (form) {
|
||||
|
|
@ -276,12 +272,8 @@ const normalizeNewlines = function(str) {
|
|||
return this.initRefresh();
|
||||
};
|
||||
|
||||
Notes.prototype.handleCreateChanges = function(noteEntity) {
|
||||
Notes.prototype.handleSlashCommands = function(noteEntity) {
|
||||
var votesBlock;
|
||||
if (typeof noteEntity === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (noteEntity.commands_changes) {
|
||||
if ('merge' in noteEntity.commands_changes) {
|
||||
$.get(mrRefreshWidgetUrl);
|
||||
|
|
@ -556,24 +548,29 @@ const normalizeNewlines = function(str) {
|
|||
Adds new note to list.
|
||||
*/
|
||||
|
||||
Notes.prototype.addNote = function(xhr, note, status) {
|
||||
this.handleCreateChanges(note);
|
||||
Notes.prototype.addNote = function($form, note) {
|
||||
return this.renderNote(note);
|
||||
};
|
||||
|
||||
Notes.prototype.addNoteError = function(xhr, note, status) {
|
||||
return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', this.parentTimeline);
|
||||
Notes.prototype.addNoteError = ($form) => {
|
||||
let formParentTimeline;
|
||||
if ($form.hasClass('js-main-target-form')) {
|
||||
formParentTimeline = $form.parents('.timeline');
|
||||
} else if ($form.hasClass('js-discussion-note-form')) {
|
||||
formParentTimeline = $form.closest('.discussion-notes').find('.notes');
|
||||
}
|
||||
return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline);
|
||||
};
|
||||
|
||||
Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.');
|
||||
|
||||
/*
|
||||
Called in response to the new note form being submitted
|
||||
|
||||
Adds new note to list.
|
||||
*/
|
||||
|
||||
Notes.prototype.addDiscussionNote = function(xhr, note, status) {
|
||||
var $form = $(xhr.target);
|
||||
|
||||
Notes.prototype.addDiscussionNote = function($form, note, isNewDiffComment) {
|
||||
if ($form.attr('data-resolve-all') != null) {
|
||||
var projectPath = $form.data('project-path');
|
||||
var discussionId = $form.data('discussion-id');
|
||||
|
|
@ -586,7 +583,9 @@ const normalizeNewlines = function(str) {
|
|||
|
||||
this.renderNote(note, $form);
|
||||
// cleanup after successfully creating a diff/discussion note
|
||||
this.removeDiscussionNoteForm($form);
|
||||
if (isNewDiffComment) {
|
||||
this.removeDiscussionNoteForm($form);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
|
|
@ -596,17 +595,18 @@ const normalizeNewlines = function(str) {
|
|||
*/
|
||||
|
||||
Notes.prototype.updateNote = function(_xhr, noteEntity, _status) {
|
||||
var $html, $note_li;
|
||||
var $noteEntityEl, $note_li;
|
||||
// Convert returned HTML to a jQuery object so we can modify it further
|
||||
$html = $(noteEntity.html);
|
||||
$noteEntityEl = $(noteEntity.html);
|
||||
$noteEntityEl.addClass('fade-in-full');
|
||||
this.revertNoteEditForm();
|
||||
gl.utils.localTimeAgo($('.js-timeago', $html));
|
||||
$html.renderGFM();
|
||||
$html.find('.js-task-list-container').taskList('enable');
|
||||
gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl));
|
||||
$noteEntityEl.renderGFM();
|
||||
$noteEntityEl.find('.js-task-list-container').taskList('enable');
|
||||
// Find the note's `li` element by ID and replace it with the updated HTML
|
||||
$note_li = $('.note-row-' + noteEntity.id);
|
||||
|
||||
$note_li.replaceWith($html);
|
||||
$note_li.replaceWith($noteEntityEl);
|
||||
|
||||
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
|
||||
gl.diffNotesCompileComponents();
|
||||
|
|
@ -698,7 +698,7 @@ const normalizeNewlines = function(str) {
|
|||
var $editForm = $(selector);
|
||||
|
||||
$editForm.insertBefore('.notes-form');
|
||||
$editForm.find('.js-comment-button').enable();
|
||||
$editForm.find('.js-comment-save-button').enable();
|
||||
$editForm.find('.js-finish-edit-warning').hide();
|
||||
};
|
||||
|
||||
|
|
@ -982,14 +982,6 @@ const normalizeNewlines = function(str) {
|
|||
return this.refresh();
|
||||
};
|
||||
|
||||
Notes.prototype.updateCloseButton = function(e) {
|
||||
var closebtn, form, textarea;
|
||||
textarea = $(e.target);
|
||||
form = textarea.parents('form');
|
||||
closebtn = form.find('.js-note-target-close');
|
||||
return closebtn.text(closebtn.data('original-text'));
|
||||
};
|
||||
|
||||
Notes.prototype.updateTargetButtons = function(e) {
|
||||
var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea;
|
||||
textarea = $(e.target);
|
||||
|
|
@ -1078,17 +1070,6 @@ const normalizeNewlines = function(str) {
|
|||
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount);
|
||||
};
|
||||
|
||||
Notes.prototype.resolveDiscussion = function() {
|
||||
var $this = $(this);
|
||||
var discussionId = $this.attr('data-discussion-id');
|
||||
|
||||
$this
|
||||
.closest('form')
|
||||
.attr('data-discussion-id', discussionId)
|
||||
.attr('data-resolve-all', 'true')
|
||||
.attr('data-project-path', $this.attr('data-project-path'));
|
||||
};
|
||||
|
||||
Notes.prototype.toggleCommitList = function(e) {
|
||||
const $element = $(e.currentTarget);
|
||||
const $closestSystemCommitList = $element.siblings('.system-note-commit-list');
|
||||
|
|
@ -1137,7 +1118,7 @@ const normalizeNewlines = function(str) {
|
|||
Notes.animateAppendNote = function(noteHtml, $notesList) {
|
||||
const $note = $(noteHtml);
|
||||
|
||||
$note.addClass('fade-in').renderGFM();
|
||||
$note.addClass('fade-in-full').renderGFM();
|
||||
$notesList.append($note);
|
||||
return $note;
|
||||
};
|
||||
|
|
@ -1150,6 +1131,254 @@ const normalizeNewlines = function(str) {
|
|||
return $updatedNote;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get data from Form attributes to use for saving/submitting comment.
|
||||
*/
|
||||
Notes.prototype.getFormData = function($form) {
|
||||
return {
|
||||
formData: $form.serialize(),
|
||||
formContent: $form.find('.js-note-text').val(),
|
||||
formAction: $form.attr('action'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Identify if comment has any slash commands
|
||||
*/
|
||||
Notes.prototype.hasSlashCommands = function(formContent) {
|
||||
return REGEX_SLASH_COMMANDS.test(formContent);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove slash commands and leave comment with pure message
|
||||
*/
|
||||
Notes.prototype.stripSlashCommands = function(formContent) {
|
||||
return formContent.replace(REGEX_SLASH_COMMANDS, '').trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create placeholder note DOM element populated with comment body
|
||||
* that we will show while comment is being posted.
|
||||
* Once comment is _actually_ posted on server, we will have final element
|
||||
* in response that we will show in place of this temporary element.
|
||||
*/
|
||||
Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) {
|
||||
const discussionClass = isDiscussionNote ? 'discussion' : '';
|
||||
const $tempNote = $(
|
||||
`<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
|
||||
<div class="timeline-entry-inner">
|
||||
<div class="timeline-icon">
|
||||
<a href="/${currentUsername}"><span class="dummy-avatar"></span></a>
|
||||
</div>
|
||||
<div class="timeline-content ${discussionClass}">
|
||||
<div class="note-header">
|
||||
<div class="note-header-info">
|
||||
<a href="/${currentUsername}">
|
||||
<span class="hidden-xs">${currentUserFullname}</span>
|
||||
<span class="note-headline-light">@${currentUsername}</span>
|
||||
</a>
|
||||
<span class="note-headline-light">
|
||||
<i class="fa fa-spinner fa-spin" aria-label="Comment is being posted" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note-body">
|
||||
<div class="note-text">
|
||||
<p>${formContent}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>`
|
||||
);
|
||||
|
||||
return $tempNote;
|
||||
};
|
||||
|
||||
/**
|
||||
* This method does following tasks step-by-step whenever a new comment
|
||||
* is submitted by user (both main thread comments as well as discussion comments).
|
||||
*
|
||||
* 1) Get Form metadata
|
||||
* 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve
|
||||
* 3) Build temporary placeholder element (using `createPlaceholderNote`)
|
||||
* 4) Show placeholder note on UI
|
||||
* 5) Perform network request to submit the note using `gl.utils.ajaxPost`
|
||||
* a) If request is successfully completed
|
||||
* 1. Remove placeholder element
|
||||
* 2. Show submitted Note element
|
||||
* 3. Perform post-submit errands
|
||||
* a. Mark discussion as resolved if comment submission was for resolve.
|
||||
* b. Reset comment form to original state.
|
||||
* b) If request failed
|
||||
* 1. Remove placeholder element
|
||||
* 2. Show error Flash message about failure
|
||||
*/
|
||||
Notes.prototype.postComment = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get Form metadata
|
||||
const $submitBtn = $(e.target);
|
||||
let $form = $submitBtn.parents('form');
|
||||
const $closeBtn = $form.find('.js-note-target-close');
|
||||
const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion';
|
||||
const isMainForm = $form.hasClass('js-main-target-form');
|
||||
const isDiscussionForm = $form.hasClass('js-discussion-note-form');
|
||||
const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button');
|
||||
const { formData, formContent, formAction } = this.getFormData($form);
|
||||
const uniqueId = _.uniqueId('tempNote_');
|
||||
let $notesContainer;
|
||||
let tempFormContent;
|
||||
|
||||
// Get reference to notes container based on type of comment
|
||||
if (isDiscussionForm) {
|
||||
$notesContainer = $form.parent('.discussion-notes').find('.notes');
|
||||
} else if (isMainForm) {
|
||||
$notesContainer = $('ul.main-notes-list');
|
||||
}
|
||||
|
||||
// If comment is to resolve discussion, disable submit buttons while
|
||||
// comment posting is finished.
|
||||
if (isDiscussionResolve) {
|
||||
$submitBtn.disable();
|
||||
$form.find('.js-comment-submit-button').disable();
|
||||
}
|
||||
|
||||
tempFormContent = formContent;
|
||||
if (this.hasSlashCommands(formContent)) {
|
||||
tempFormContent = this.stripSlashCommands(formContent);
|
||||
}
|
||||
|
||||
if (tempFormContent) {
|
||||
// Show placeholder note
|
||||
$notesContainer.append(this.createPlaceholderNote({
|
||||
formContent: tempFormContent,
|
||||
uniqueId,
|
||||
isDiscussionNote,
|
||||
currentUsername: gon.current_username,
|
||||
currentUserFullname: gon.current_user_fullname,
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear the form textarea
|
||||
if ($notesContainer.length) {
|
||||
if (isMainForm) {
|
||||
this.resetMainTargetForm(e);
|
||||
} else if (isDiscussionForm) {
|
||||
this.removeDiscussionNoteForm($form);
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-disable promise/catch-or-return */
|
||||
// Make request to submit comment on server
|
||||
gl.utils.ajaxPost(formAction, formData)
|
||||
.then((note) => {
|
||||
// Submission successful! remove placeholder
|
||||
$notesContainer.find(`#${uniqueId}`).remove();
|
||||
|
||||
// Check if this was discussion comment
|
||||
if (isDiscussionForm) {
|
||||
// Remove flash-container
|
||||
$notesContainer.find('.flash-container').remove();
|
||||
|
||||
// If comment intends to resolve discussion, do the same.
|
||||
if (isDiscussionResolve) {
|
||||
$form
|
||||
.attr('data-discussion-id', $submitBtn.data('discussion-id'))
|
||||
.attr('data-resolve-all', 'true')
|
||||
.attr('data-project-path', $submitBtn.data('project-path'));
|
||||
}
|
||||
|
||||
// Show final note element on UI
|
||||
this.addDiscussionNote($form, note, $notesContainer.length === 0);
|
||||
|
||||
// append flash-container to the Notes list
|
||||
if ($notesContainer.length) {
|
||||
$notesContainer.append('<div class="flash-container" style="display: none;"></div>');
|
||||
}
|
||||
} else if (isMainForm) { // Check if this was main thread comment
|
||||
// Show final note element on UI and perform form and action buttons cleanup
|
||||
this.addNote($form, note);
|
||||
this.reenableTargetFormSubmitButton(e);
|
||||
}
|
||||
|
||||
if (note.commands_changes) {
|
||||
this.handleSlashCommands(note);
|
||||
}
|
||||
|
||||
$form.trigger('ajax:success', [note]);
|
||||
}).fail(() => {
|
||||
// Submission failed, remove placeholder note and show Flash error message
|
||||
$notesContainer.find(`#${uniqueId}`).remove();
|
||||
|
||||
// Show form again on UI on failure
|
||||
if (isDiscussionForm && $notesContainer.length) {
|
||||
const replyButton = $notesContainer.parent().find('.js-discussion-reply-button');
|
||||
$.proxy(this.replyToDiscussionNote, replyButton[0], { target: replyButton[0] }).call();
|
||||
$form = $notesContainer.parent().find('form');
|
||||
}
|
||||
|
||||
$form.find('.js-note-text').val(formContent);
|
||||
this.reenableTargetFormSubmitButton(e);
|
||||
this.addNoteError($form);
|
||||
});
|
||||
|
||||
return $closeBtn.text($closeBtn.data('original-text'));
|
||||
};
|
||||
|
||||
/**
|
||||
* This method does following tasks step-by-step whenever an existing comment
|
||||
* is updated by user (both main thread comments as well as discussion comments).
|
||||
*
|
||||
* 1) Get Form metadata
|
||||
* 2) Update note element with new content
|
||||
* 3) Perform network request to submit the updated note using `gl.utils.ajaxPost`
|
||||
* a) If request is successfully completed
|
||||
* 1. Show submitted Note element
|
||||
* b) If request failed
|
||||
* 1. Revert Note element to original content
|
||||
* 2. Show error Flash message about failure
|
||||
*/
|
||||
Notes.prototype.updateComment = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get Form metadata
|
||||
const $submitBtn = $(e.target);
|
||||
const $form = $submitBtn.parents('form');
|
||||
const $closeBtn = $form.find('.js-note-target-close');
|
||||
const $editingNote = $form.parents('.note.is-editing');
|
||||
const $noteBody = $editingNote.find('.js-task-list-container');
|
||||
const $noteBodyText = $noteBody.find('.note-text');
|
||||
const { formData, formContent, formAction } = this.getFormData($form);
|
||||
|
||||
// Cache original comment content
|
||||
const cachedNoteBodyText = $noteBodyText.html();
|
||||
|
||||
// Show updated comment content temporarily
|
||||
$noteBodyText.html(formContent);
|
||||
$editingNote.removeClass('is-editing').addClass('being-posted fade-in-half');
|
||||
$editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>');
|
||||
|
||||
/* eslint-disable promise/catch-or-return */
|
||||
// Make request to update comment on server
|
||||
gl.utils.ajaxPost(formAction, formData)
|
||||
.then((note) => {
|
||||
// Submission successful! render final note element
|
||||
this.updateNote(null, note, null);
|
||||
})
|
||||
.fail(() => {
|
||||
// Submission failed, revert back to original note
|
||||
$noteBodyText.html(cachedNoteBodyText);
|
||||
$editingNote.removeClass('being-posted fade-in');
|
||||
$editingNote.find('.fa.fa-spinner').remove();
|
||||
|
||||
// Show Flash message about failure
|
||||
this.updateNoteError();
|
||||
});
|
||||
|
||||
return $closeBtn.text($closeBtn.data('original-text'));
|
||||
};
|
||||
|
||||
return Notes;
|
||||
})();
|
||||
}).call(window);
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
/* global Flash */
|
||||
import StatusIconEntityMap from '../../ci_status_icons';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
stage: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
builds: '',
|
||||
spinner: '<span class="fa fa-spinner fa-spin"></span>',
|
||||
};
|
||||
},
|
||||
|
||||
updated() {
|
||||
if (this.builds) {
|
||||
this.stopDropdownClickPropagation();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchBuilds(e) {
|
||||
const ariaExpanded = e.currentTarget.attributes['aria-expanded'];
|
||||
|
||||
if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null;
|
||||
|
||||
return this.$http.get(this.stage.dropdown_path)
|
||||
.then((response) => {
|
||||
this.builds = JSON.parse(response.body).html;
|
||||
})
|
||||
.catch(() => {
|
||||
// If dropdown is opened we'll close it.
|
||||
if (this.$el.classList.contains('open')) {
|
||||
$(this.$refs.dropdown).dropdown('toggle');
|
||||
}
|
||||
|
||||
const flash = new Flash('Something went wrong on our end.');
|
||||
return flash;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* When the user right clicks or cmd/ctrl + click in the job name
|
||||
* the dropdown should not be closed and the link should open in another tab,
|
||||
* so we stop propagation of the click event inside the dropdown.
|
||||
*
|
||||
* Since this component is rendered multiple times per page we need to guarantee we only
|
||||
* target the click event of this component.
|
||||
*/
|
||||
stopDropdownClickPropagation() {
|
||||
$(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
|
||||
.on('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
buildsOrSpinner() {
|
||||
return this.builds ? this.builds : this.spinner;
|
||||
},
|
||||
dropdownClass() {
|
||||
if (this.builds) return 'js-builds-dropdown-container';
|
||||
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() {
|
||||
return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
|
||||
},
|
||||
svgHTML() {
|
||||
return StatusIconEntityMap[this.stage.status.icon];
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<button
|
||||
@click="fetchBuilds($event)"
|
||||
:class="triggerButtonClass"
|
||||
:title="stage.title"
|
||||
data-placement="top"
|
||||
data-toggle="dropdown"
|
||||
type="button"
|
||||
:aria-label="stage.title"
|
||||
ref="dropdown">
|
||||
<span
|
||||
v-html="svgHTML"
|
||||
aria-hidden="true">
|
||||
</span>
|
||||
<i
|
||||
class="fa fa-caret-down"
|
||||
aria-hidden="true" />
|
||||
</button>
|
||||
<ul
|
||||
ref="dropdown-content"
|
||||
class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
|
||||
<div
|
||||
class="arrow-up"
|
||||
aria-hidden="true"></div>
|
||||
<div
|
||||
:class="dropdownClass"
|
||||
class="js-builds-dropdown-list scrollable-menu"
|
||||
v-html="buildsOrSpinner">
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
<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 */
|
||||
import StatusIconEntityMap from '../../ci_status_icons';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
stage: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
updateDropdown: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
dropdownContent: '',
|
||||
endpoint: this.stage.dropdown_path,
|
||||
};
|
||||
},
|
||||
|
||||
updated() {
|
||||
if (this.dropdownContent.length > 0) {
|
||||
this.stopDropdownClickPropagation();
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
updateDropdown() {
|
||||
if (this.updateDropdown &&
|
||||
this.isDropdownOpen() &&
|
||||
!this.isLoading) {
|
||||
this.fetchJobs();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClickStage() {
|
||||
if (!this.isDropdownOpen()) {
|
||||
this.isLoading = true;
|
||||
this.fetchJobs();
|
||||
}
|
||||
},
|
||||
|
||||
fetchJobs() {
|
||||
this.$http.get(this.endpoint)
|
||||
.then((response) => {
|
||||
this.dropdownContent = response.json().html;
|
||||
this.isLoading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.closeDropdown();
|
||||
this.isLoading = false;
|
||||
|
||||
const flash = new Flash('Something went wrong on our end.');
|
||||
return flash;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* When the user right clicks or cmd/ctrl + click in the job name
|
||||
* the dropdown should not be closed and the link should open in another tab,
|
||||
* so we stop propagation of the click event inside the dropdown.
|
||||
*
|
||||
* Since this component is rendered multiple times per page we need to guarantee we only
|
||||
* target the click event of this component.
|
||||
*/
|
||||
stopDropdownClickPropagation() {
|
||||
$(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
|
||||
.on('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
},
|
||||
|
||||
closeDropdown() {
|
||||
if (this.isDropdownOpen()) {
|
||||
$(this.$refs.dropdown).dropdown('toggle');
|
||||
}
|
||||
},
|
||||
|
||||
isDropdownOpen() {
|
||||
return this.$el.classList.contains('open');
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
dropdownClass() {
|
||||
return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
|
||||
},
|
||||
|
||||
triggerButtonClass() {
|
||||
return `ci-status-icon-${this.stage.status.group}`;
|
||||
},
|
||||
|
||||
svgIcon() {
|
||||
return StatusIconEntityMap[this.stage.status.icon];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dropdown">
|
||||
<button
|
||||
:class="triggerButtonClass"
|
||||
@click="onClickStage"
|
||||
class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button"
|
||||
:title="stage.title"
|
||||
data-placement="top"
|
||||
data-toggle="dropdown"
|
||||
type="button"
|
||||
id="stageDropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false">
|
||||
|
||||
<span
|
||||
v-html="svgIcon"
|
||||
aria-hidden="true"
|
||||
:aria-label="stage.title">
|
||||
</span>
|
||||
|
||||
<i
|
||||
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
|
||||
class="text-center"
|
||||
v-if="isLoading">
|
||||
<i
|
||||
class="fa fa-spin fa-spinner"
|
||||
aria-hidden="true"
|
||||
aria-label="Loading">
|
||||
</i>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-else
|
||||
v-html="dropdownContent">
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</script>
|
||||
|
|
@ -49,6 +49,7 @@ export default {
|
|||
isLoading: false,
|
||||
hasError: false,
|
||||
isMakingRequest: false,
|
||||
updateGraphDropdown: false,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -198,15 +199,21 @@ export default {
|
|||
this.store.storePagination(response.headers);
|
||||
|
||||
this.isLoading = false;
|
||||
this.updateGraphDropdown = true;
|
||||
},
|
||||
|
||||
errorCallback() {
|
||||
this.hasError = true;
|
||||
this.isLoading = false;
|
||||
this.updateGraphDropdown = false;
|
||||
},
|
||||
|
||||
setIsMakingRequest(isMakingRequest) {
|
||||
this.isMakingRequest = isMakingRequest;
|
||||
|
||||
if (isMakingRequest) {
|
||||
this.updateGraphDropdown = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -263,7 +270,9 @@ export default {
|
|||
|
||||
<pipelines-table-component
|
||||
:pipelines="state.pipelines"
|
||||
:service="service"/>
|
||||
:service="service"
|
||||
:update-graph-dropdown="updateGraphDropdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<gl-pagination
|
||||
|
|
|
|||
|
|
@ -40,6 +40,6 @@ export default class PipelinesService {
|
|||
* @return {Promise}
|
||||
*/
|
||||
postAction(endpoint) {
|
||||
return Vue.http.post(endpoint, {}, { emulateJSON: true });
|
||||
return Vue.http.post(`${endpoint}.json`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
// MarkdownPreview
|
||||
//
|
||||
// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview,
|
||||
// and showing a warning when more than `x` users are referenced.
|
||||
// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview
|
||||
// (including the explanation of slash commands), and showing a warning when
|
||||
// more than `x` users are referenced.
|
||||
//
|
||||
(function () {
|
||||
var lastTextareaPreviewed;
|
||||
|
|
@ -17,32 +18,45 @@
|
|||
|
||||
// Minimum number of users referenced before triggering a warning
|
||||
MarkdownPreview.prototype.referenceThreshold = 10;
|
||||
MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
|
||||
|
||||
MarkdownPreview.prototype.ajaxCache = {};
|
||||
|
||||
MarkdownPreview.prototype.showPreview = function ($form) {
|
||||
var mdText;
|
||||
var preview = $form.find('.js-md-preview');
|
||||
var url = preview.data('url');
|
||||
if (preview.hasClass('md-preview-loading')) {
|
||||
return;
|
||||
}
|
||||
mdText = $form.find('textarea.markdown-area').val();
|
||||
|
||||
if (mdText.trim().length === 0) {
|
||||
preview.text('Nothing to preview.');
|
||||
preview.text(this.emptyMessage);
|
||||
this.hideReferencedUsers($form);
|
||||
} else {
|
||||
preview.addClass('md-preview-loading').text('Loading...');
|
||||
this.fetchMarkdownPreview(mdText, (function (response) {
|
||||
preview.removeClass('md-preview-loading').html(response.body);
|
||||
this.fetchMarkdownPreview(mdText, url, (function (response) {
|
||||
var body;
|
||||
if (response.body.length > 0) {
|
||||
body = response.body;
|
||||
} else {
|
||||
body = this.emptyMessage;
|
||||
}
|
||||
|
||||
preview.removeClass('md-preview-loading').html(body);
|
||||
preview.renderGFM();
|
||||
this.renderReferencedUsers(response.references.users, $form);
|
||||
|
||||
if (response.references.commands) {
|
||||
this.renderReferencedCommands(response.references.commands, $form);
|
||||
}
|
||||
}).bind(this));
|
||||
}
|
||||
};
|
||||
|
||||
MarkdownPreview.prototype.fetchMarkdownPreview = function (text, success) {
|
||||
if (!window.preview_markdown_path) {
|
||||
MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
if (text === this.ajaxCache.text) {
|
||||
|
|
@ -51,7 +65,7 @@
|
|||
}
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: window.preview_markdown_path,
|
||||
url: url,
|
||||
data: {
|
||||
text: text
|
||||
},
|
||||
|
|
@ -83,6 +97,22 @@
|
|||
}
|
||||
};
|
||||
|
||||
MarkdownPreview.prototype.hideReferencedCommands = function ($form) {
|
||||
$form.find('.referenced-commands').hide();
|
||||
};
|
||||
|
||||
MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) {
|
||||
var referencedCommands;
|
||||
referencedCommands = $form.find('.referenced-commands');
|
||||
if (commands.length > 0) {
|
||||
referencedCommands.html(commands);
|
||||
referencedCommands.show();
|
||||
} else {
|
||||
referencedCommands.html('');
|
||||
referencedCommands.hide();
|
||||
}
|
||||
};
|
||||
|
||||
return MarkdownPreview;
|
||||
}());
|
||||
|
||||
|
|
@ -137,6 +167,8 @@
|
|||
$form.find('.md-write-holder').show();
|
||||
$form.find('textarea.markdown-area').focus();
|
||||
$form.find('.md-preview-holder').hide();
|
||||
|
||||
markdownPreview.hideReferencedCommands($form);
|
||||
});
|
||||
|
||||
$(document).on('markdown-preview:toggle', function (e, keyboardEvent) {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,18 @@ export default {
|
|||
pipelines: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => ([]),
|
||||
},
|
||||
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
updateGraphDropdown: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
components: {
|
||||
|
|
@ -40,7 +45,9 @@ export default {
|
|||
v-bind:model="model">
|
||||
<tr is="pipelines-table-row-component"
|
||||
:pipeline="model"
|
||||
:service="service"></tr>
|
||||
:service="service"
|
||||
:update-graph-dropdown="updateGraphDropdown"
|
||||
/>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import AsyncButtonComponent from '../../pipelines/components/async_button.vue';
|
|||
import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions';
|
||||
import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
|
||||
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 PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
|
||||
import CommitComponent from './commit';
|
||||
|
|
@ -24,6 +24,12 @@ export default {
|
|||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
updateGraphDropdown: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
components: {
|
||||
|
|
@ -213,7 +219,10 @@ export default {
|
|||
<div class="stage-container dropdown js-mini-pipeline-graph"
|
||||
v-if="pipeline.details.stages.length > 0"
|
||||
v-for="stage in pipeline.details.stages">
|
||||
<dropdown-stage :stage="stage"/>
|
||||
|
||||
<dropdown-stage
|
||||
:stage="stage"
|
||||
:update-dropdown="updateGraphDropdown"/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
__,
|
||||
n__,
|
||||
s__,
|
||||
} from '../locale';
|
||||
|
||||
export default (Vue) => {
|
||||
Vue.mixin({
|
||||
methods: {
|
||||
/**
|
||||
Translates `text`
|
||||
|
||||
@param text The text to be translated
|
||||
@returns {String} The translated text
|
||||
**/
|
||||
__,
|
||||
/**
|
||||
Translate the text with a number
|
||||
if the number is more than 1 it will use the `pluralText` translation.
|
||||
This method allows for contexts, see below re. contexts
|
||||
|
||||
@param text Singular text to translate (eg. '%d day')
|
||||
@param pluralText Plural text to translate (eg. '%d days')
|
||||
@param count Number to decide which translation to use (eg. 2)
|
||||
@returns {String} Translated text with the number replaced (eg. '2 days')
|
||||
**/
|
||||
n__,
|
||||
/**
|
||||
Translate context based text
|
||||
Either pass in the context translation like `Context|Text to translate`
|
||||
or allow for dynamic text by doing passing in the context first & then the text to translate
|
||||
|
||||
@param keyOrContext Can be either the key to translate including the context
|
||||
(eg. 'Context|Text') or just the context for the translation
|
||||
(eg. 'Context')
|
||||
@param key Is the dynamic variable you want to be translated
|
||||
@returns {String} Translated context based text
|
||||
**/
|
||||
s__,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -159,3 +159,31 @@ a {
|
|||
.fade-in {
|
||||
animation: fadeIn $fade-in-duration 1;
|
||||
}
|
||||
|
||||
@keyframes fadeInHalf {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-half {
|
||||
animation: fadeInHalf $fade-in-duration 1;
|
||||
}
|
||||
|
||||
@keyframes fadeInFull {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-full {
|
||||
animation: fadeInFull $fade-in-duration 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@
|
|||
}
|
||||
|
||||
.stage-nav-item {
|
||||
display: block;
|
||||
display: flex;
|
||||
line-height: 65px;
|
||||
border-top: 1px solid transparent;
|
||||
border-bottom: 1px solid transparent;
|
||||
|
|
@ -209,14 +209,10 @@
|
|||
}
|
||||
|
||||
.stage-nav-item-cell {
|
||||
float: left;
|
||||
|
||||
&.stage-name {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
&.stage-median {
|
||||
width: 35%;
|
||||
margin-left: auto;
|
||||
margin-right: $gl-padding;
|
||||
min-width: calc(35% - #{$gl-padding});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -482,6 +482,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.target-branch-select-dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.assign-to-me-link {
|
||||
padding-left: 12px;
|
||||
white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,25 @@ ul.notes {
|
|||
position: relative;
|
||||
border-bottom: 1px solid $white-normal;
|
||||
|
||||
&.being-posted {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
|
||||
.dummy-avatar {
|
||||
display: inline-block;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: $kdb-border;
|
||||
border: 1px solid darken($kdb-border, 25%);
|
||||
}
|
||||
|
||||
.note-headline-light,
|
||||
.fa-spinner {
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
&.note-discussion {
|
||||
&.timeline-entry {
|
||||
padding: 14px 10px;
|
||||
|
|
@ -687,6 +706,10 @@ ul.notes {
|
|||
}
|
||||
}
|
||||
|
||||
.discussion-notes .flash-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// Merge request notes in diffs
|
||||
.diff-file {
|
||||
// Diff is side by side
|
||||
|
|
|
|||
|
|
@ -781,16 +781,11 @@
|
|||
}
|
||||
|
||||
.scrollable-menu {
|
||||
padding: 0;
|
||||
max-height: 245px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// Loading icon
|
||||
.builds-dropdown-loading {
|
||||
margin: 0 auto;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
// Action icon on the right
|
||||
a.ci-action-icon-wrapper {
|
||||
color: $action-icon-color;
|
||||
|
|
@ -893,30 +888,29 @@
|
|||
* Top arrow in the dropdown in the mini pipeline graph
|
||||
*/
|
||||
.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 {
|
||||
border-width: 0 5px 5px;
|
||||
border-bottom-color: $border-color;
|
||||
}
|
||||
&::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;
|
||||
}
|
||||
|
||||
&::after {
|
||||
margin-top: 1px;
|
||||
border-bottom-color: $white-light;
|
||||
}
|
||||
&::before {
|
||||
border-width: 0 5px 5px;
|
||||
border-bottom-color: $border-color;
|
||||
}
|
||||
|
||||
&::after {
|
||||
margin-top: 1px;
|
||||
border-bottom-color: $white-light;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ class ApplicationController < ActionController::Base
|
|||
before_action :configure_permitted_parameters, if: :devise_controller?
|
||||
before_action :require_email, unless: :devise_controller?
|
||||
|
||||
around_action :set_locale
|
||||
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
helper_method :can?, :current_application_settings
|
||||
|
|
@ -269,4 +271,12 @@ class ApplicationController < ActionController::Base
|
|||
def u2f_app_id
|
||||
request.base_url
|
||||
end
|
||||
|
||||
def set_locale
|
||||
Gitlab::I18n.set_locale(current_user)
|
||||
|
||||
yield
|
||||
ensure
|
||||
Gitlab::I18n.reset_locale
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
module MarkdownPreview
|
||||
private
|
||||
|
||||
def render_markdown_preview(text, markdown_context = {})
|
||||
render json: {
|
||||
body: view_context.markdown(text, markdown_context),
|
||||
references: {
|
||||
users: preview_referenced_users(text)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def preview_referenced_users(text)
|
||||
extractor = Gitlab::ReferenceExtractor.new(@project, current_user)
|
||||
extractor.analyze(text, author: current_user)
|
||||
|
||||
extractor.users.map(&:username)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
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
|
||||
|
|
@ -67,7 +67,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
def omniauth_error
|
||||
@provider = params[:provider]
|
||||
@error = params[:error]
|
||||
render 'errors/omniauth_error', layout: "errors", status: 422
|
||||
render 'errors/omniauth_error', layout: "oauth_error", status: 422
|
||||
end
|
||||
|
||||
def cas3
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ class ProfilesController < Profiles::ApplicationController
|
|||
:twitter,
|
||||
:username,
|
||||
:website_url,
|
||||
:organization
|
||||
:organization,
|
||||
:preferred_language
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -89,4 +89,8 @@ class Projects::ApplicationController < ApplicationController
|
|||
def builds_enabled
|
||||
return render_404 unless @project.feature_available?(:builds, current_user)
|
||||
end
|
||||
|
||||
def require_pages_enabled!
|
||||
not_found unless Gitlab.config.pages.enabled
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
class Projects::ArtifactsController < Projects::ApplicationController
|
||||
include ExtractsPath
|
||||
include RendersBlob
|
||||
|
||||
layout 'project'
|
||||
before_action :authorize_read_build!
|
||||
before_action :authorize_update_build!, only: [:keep]
|
||||
before_action :extract_ref_name_and_path
|
||||
before_action :validate_artifacts!
|
||||
before_action :set_path_and_entry, only: [:file, :raw]
|
||||
|
||||
def download
|
||||
if artifacts_file.file_storage?
|
||||
|
|
@ -24,15 +26,24 @@ class Projects::ArtifactsController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def file
|
||||
entry = build.artifacts_metadata_entry(params[:path])
|
||||
blob = @entry.blob
|
||||
override_max_blob_size(blob)
|
||||
|
||||
if entry.exists?
|
||||
send_artifacts_entry(build, entry)
|
||||
else
|
||||
render_404
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
render 'file'
|
||||
end
|
||||
|
||||
format.json do
|
||||
render_blob_json(blob)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def raw
|
||||
send_artifacts_entry(build, @entry)
|
||||
end
|
||||
|
||||
def keep
|
||||
build.keep_artifacts!
|
||||
redirect_to namespace_project_build_path(project.namespace, project, build)
|
||||
|
|
@ -81,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController
|
|||
def artifacts_file
|
||||
@artifacts_file ||= build.artifacts_file
|
||||
end
|
||||
|
||||
def set_path_and_entry
|
||||
@path = params[:path]
|
||||
@entry = build.artifacts_metadata_entry(@path)
|
||||
|
||||
render_404 unless @entry.exists?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ class Projects::DeployKeysController < Projects::ApplicationController
|
|||
layout "project_settings"
|
||||
|
||||
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
|
||||
|
||||
def new
|
||||
|
|
@ -19,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
|
|||
@key = DeployKey.new(deploy_key_params.merge(user: current_user))
|
||||
|
||||
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
|
||||
redirect_to_repository_settings(@project)
|
||||
end
|
||||
|
|
@ -27,7 +32,10 @@ class Projects::DeployKeysController < Projects::ApplicationController
|
|||
def enable
|
||||
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
|
||||
|
||||
def disable
|
||||
|
|
@ -35,7 +43,11 @@ class Projects::DeployKeysController < Projects::ApplicationController
|
|||
return render_404 unless deploy_key_project
|
||||
|
||||
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
|
||||
|
||||
protected
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
class Projects::PagesController < Projects::ApplicationController
|
||||
layout 'project_settings'
|
||||
|
||||
before_action :require_pages_enabled!
|
||||
before_action :authorize_read_pages!, only: [:show]
|
||||
before_action :authorize_update_pages!, except: [:show]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
class Projects::PagesDomainsController < Projects::ApplicationController
|
||||
layout 'project_settings'
|
||||
|
||||
before_action :require_pages_enabled!
|
||||
before_action :authorize_update_pages!, except: [:show]
|
||||
before_action :domain, only: [:show, :destroy]
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ class Projects::PipelinesController < Projects::ApplicationController
|
|||
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
|
||||
before_action :builds_enabled, only: :charts
|
||||
|
||||
wrap_parameters Ci::Pipeline
|
||||
|
||||
def index
|
||||
@scope = params[:scope]
|
||||
@pipelines = PipelinesFinder
|
||||
|
|
@ -92,13 +94,25 @@ class Projects::PipelinesController < Projects::ApplicationController
|
|||
def retry
|
||||
pipeline.retry_failed(current_user)
|
||||
|
||||
redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
|
||||
end
|
||||
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
def cancel
|
||||
pipeline.cancel_running
|
||||
|
||||
redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project)
|
||||
end
|
||||
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
def charts
|
||||
|
|
|
|||
|
|
@ -1,33 +1,11 @@
|
|||
class Projects::UploadsController < Projects::ApplicationController
|
||||
include UploadsActions
|
||||
|
||||
skip_before_action :project, :repository,
|
||||
if: -> { action_name == 'show' && image_or_video? }
|
||||
|
||||
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
|
||||
|
||||
def uploader
|
||||
|
|
@ -52,4 +30,10 @@ class Projects::UploadsController < Projects::ApplicationController
|
|||
def image_or_video?
|
||||
uploader && uploader.file.exists? && uploader.image_or_video?
|
||||
end
|
||||
|
||||
def uploader_class
|
||||
FileUploader
|
||||
end
|
||||
|
||||
alias_method :model, :project
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
class Projects::WikisController < Projects::ApplicationController
|
||||
include MarkdownPreview
|
||||
|
||||
before_action :authorize_read_wiki!
|
||||
before_action :authorize_create_wiki!, only: [:edit, :create, :history]
|
||||
before_action :authorize_admin_wiki!, only: :destroy
|
||||
|
|
@ -97,9 +95,14 @@ class Projects::WikisController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def preview_markdown
|
||||
context = { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
|
||||
result = PreviewMarkdownService.new(@project, current_user, params).execute
|
||||
|
||||
render_markdown_preview(params[:text], context)
|
||||
render json: {
|
||||
body: view_context.markdown(result[:text], pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]),
|
||||
references: {
|
||||
users: result[:users]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
class ProjectsController < Projects::ApplicationController
|
||||
include IssuableCollections
|
||||
include ExtractsPath
|
||||
include MarkdownPreview
|
||||
|
||||
before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
|
||||
before_action :project, except: [:index, :new, :create]
|
||||
|
|
@ -240,7 +239,15 @@ class ProjectsController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def preview_markdown
|
||||
render_markdown_preview(params[:text])
|
||||
result = PreviewMarkdownService.new(@project, current_user, params).execute
|
||||
|
||||
render json: {
|
||||
body: view_context.markdown(result[:text]),
|
||||
references: {
|
||||
users: result[:users],
|
||||
commands: view_context.markdown(result[:commands])
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ class SnippetsController < ApplicationController
|
|||
include ToggleAwardEmoji
|
||||
include SpammableActions
|
||||
include SnippetsActions
|
||||
include MarkdownPreview
|
||||
include RendersBlob
|
||||
|
||||
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
|
||||
|
|
@ -90,7 +89,14 @@ class SnippetsController < ApplicationController
|
|||
end
|
||||
|
||||
def preview_markdown
|
||||
render_markdown_preview(params[:text], skip_project_check: true)
|
||||
result = PreviewMarkdownService.new(@project, current_user, params).execute
|
||||
|
||||
render json: {
|
||||
body: view_context.markdown(result[:text], skip_project_check: true),
|
||||
references: {
|
||||
users: result[:users]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
protected
|
||||
|
|
|
|||
|
|
@ -1,50 +1,43 @@
|
|||
class UploadsController < ApplicationController
|
||||
include UploadsActions
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
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?
|
||||
return render_404
|
||||
end
|
||||
|
||||
disposition = uploader.image? ? 'inline' : 'attachment'
|
||||
|
||||
expires_in 0.seconds, must_revalidate: true, private: true
|
||||
send_file uploader.file.path, disposition: disposition
|
||||
end
|
||||
before_action :find_model
|
||||
before_action :authorize_access!, only: [:show]
|
||||
before_action :authorize_create_access!, only: [:create]
|
||||
|
||||
private
|
||||
|
||||
def find_model
|
||||
unless upload_model && upload_mount
|
||||
return render_404
|
||||
end
|
||||
return render_404 unless upload_model && upload_mount
|
||||
|
||||
@model = upload_model.find(params[:id])
|
||||
end
|
||||
|
||||
def authorize_access!
|
||||
authorized =
|
||||
case @model
|
||||
when Project
|
||||
can?(current_user, :read_project, @model)
|
||||
when Group
|
||||
can?(current_user, :read_group, @model)
|
||||
case model
|
||||
when Note
|
||||
can?(current_user, :read_project, @model.project)
|
||||
else
|
||||
# No authentication required for user avatars.
|
||||
can?(current_user, :read_project, model.project)
|
||||
when User
|
||||
true
|
||||
else
|
||||
permission = "read_#{model.class.to_s.underscore}".to_sym
|
||||
|
||||
can?(current_user, permission, model)
|
||||
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
|
||||
render_404
|
||||
else
|
||||
|
|
@ -58,17 +51,44 @@ class UploadsController < ApplicationController
|
|||
"project" => Project,
|
||||
"note" => Note,
|
||||
"group" => Group,
|
||||
"appearance" => Appearance
|
||||
"appearance" => Appearance,
|
||||
"personal_snippet" => PersonalSnippet
|
||||
}
|
||||
|
||||
upload_models[params[:model]]
|
||||
end
|
||||
|
||||
def upload_mount
|
||||
return true unless params[:mounted_as]
|
||||
|
||||
upload_mounts = %w(avatar attachment file logo header_logo)
|
||||
|
||||
if upload_mounts.include?(params[:mounted_as])
|
||||
params[:mounted_as]
|
||||
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
|
||||
|
|
|
|||
|
|
@ -119,7 +119,9 @@ module BlobHelper
|
|||
end
|
||||
|
||||
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
|
||||
raw_namespace_project_snippet_path(@project.namespace, @project, @snippet)
|
||||
else
|
||||
|
|
@ -250,6 +252,8 @@ module BlobHelper
|
|||
case viewer.blob.external_storage
|
||||
when :lfs
|
||||
'it is stored in LFS'
|
||||
when :build_artifact
|
||||
'it is stored as a job artifact'
|
||||
else
|
||||
'it is stored externally'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ module BoardsHelper
|
|||
issue_link_base: namespace_project_issues_path(@project.namespace, @project),
|
||||
root_path: root_path,
|
||||
bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project),
|
||||
default_avatar: image_path(default_avatar)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ module GitlabRoutingHelper
|
|||
namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args)
|
||||
end
|
||||
|
||||
def preview_markdown_path(project, *args)
|
||||
preview_markdown_namespace_project_path(project.namespace, project, *args)
|
||||
end
|
||||
|
||||
def toggle_subscription_path(entity, *args)
|
||||
if entity.is_a?(Issue)
|
||||
toggle_subscription_namespace_project_issue_path(entity.project.namespace, entity.project, entity)
|
||||
|
|
@ -208,6 +212,8 @@ module GitlabRoutingHelper
|
|||
browse_namespace_project_build_artifacts_path(*args)
|
||||
when 'file'
|
||||
file_namespace_project_build_artifacts_path(*args)
|
||||
when 'raw'
|
||||
raw_namespace_project_build_artifacts_path(*args)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ module SortingHelper
|
|||
}
|
||||
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
|
||||
'Priority'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
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
|
|||
|
||||
# Callbacks
|
||||
after_create :reset_project_activity
|
||||
after_create :set_last_repository_updated_at, if: :push?
|
||||
|
||||
# Scopes
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
|
|
@ -357,4 +358,9 @@ class Event < ActiveRecord::Base
|
|||
def recent_update?
|
||||
project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago
|
||||
end
|
||||
|
||||
def set_last_repository_updated_at
|
||||
Project.unscoped.where(id: project_id).
|
||||
update_all(last_repository_updated_at: created_at)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ class Project < ActiveRecord::Base
|
|||
update_column(:last_activity_at, self.created_at)
|
||||
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
|
||||
|
||||
# update visibility_level of forks
|
||||
|
|
|
|||
|
|
@ -183,6 +183,6 @@ class ProjectWiki
|
|||
end
|
||||
|
||||
def update_project_activity
|
||||
@project.touch(:last_activity_at)
|
||||
@project.touch(:last_activity_at, :last_repository_updated_at)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class User < ActiveRecord::Base
|
|||
default_value_for :hide_no_password, false
|
||||
default_value_for :project_view, :files
|
||||
default_value_for :notified_of_own_activity, false
|
||||
default_value_for :preferred_language, I18n.default_locale
|
||||
|
||||
attr_encrypted :otp_secret,
|
||||
key: Gitlab::Application.secrets.otp_key_base,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@ class PersonalSnippetPolicy < BasePolicy
|
|||
can! :read_personal_snippet if @subject.public?
|
||||
return unless @user
|
||||
|
||||
if @subject.public?
|
||||
can! :comment_personal_snippet
|
||||
end
|
||||
|
||||
if @subject.author == @user
|
||||
can! :read_personal_snippet
|
||||
can! :update_personal_snippet
|
||||
can! :destroy_personal_snippet
|
||||
can! :admin_personal_snippet
|
||||
can! :comment_personal_snippet
|
||||
end
|
||||
|
||||
unless @user.external?
|
||||
|
|
@ -16,6 +21,7 @@ class PersonalSnippetPolicy < BasePolicy
|
|||
|
||||
if @subject.internal? && !@user.external?
|
||||
can! :read_personal_snippet
|
||||
can! :comment_personal_snippet
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ module Projects
|
|||
available_public_keys.any?
|
||||
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
|
||||
'projects/deploy_keys/index'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
# 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
|
||||
|
|
@ -2,6 +2,7 @@ class AnalyticsStageEntity < Grape::Entity
|
|||
include EntityDateHelper
|
||||
|
||||
expose :title
|
||||
expose :name
|
||||
expose :legend
|
||||
expose :description
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
class AnalyticsSummaryEntity < Grape::Entity
|
||||
expose :value, safe: true
|
||||
|
||||
expose :title do |object|
|
||||
object.title.pluralize(object.value)
|
||||
end
|
||||
expose :title
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
class DeployKeySerializer < BaseSerializer
|
||||
entity DeployKeyEntity
|
||||
end
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
class PreviewMarkdownService < BaseService
|
||||
def execute
|
||||
text, commands = explain_slash_commands(params[:text])
|
||||
users = find_user_references(text)
|
||||
|
||||
success(
|
||||
text: text,
|
||||
users: users,
|
||||
commands: commands.join(' ')
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def explain_slash_commands(text)
|
||||
return text, [] unless %w(Issue MergeRequest).include?(commands_target_type)
|
||||
|
||||
slash_commands_service = SlashCommands::InterpretService.new(project, current_user)
|
||||
slash_commands_service.explain(text, find_commands_target)
|
||||
end
|
||||
|
||||
def find_user_references(text)
|
||||
extractor = Gitlab::ReferenceExtractor.new(project, current_user)
|
||||
extractor.analyze(text, author: current_user)
|
||||
extractor.users.map(&:username)
|
||||
end
|
||||
|
||||
def find_commands_target
|
||||
if commands_target_id.present?
|
||||
finder = commands_target_type == 'Issue' ? IssuesFinder : MergeRequestsFinder
|
||||
finder.new(current_user, project_id: project.id).find(commands_target_id)
|
||||
else
|
||||
collection = commands_target_type == 'Issue' ? project.issues : project.merge_requests
|
||||
collection.build
|
||||
end
|
||||
end
|
||||
|
||||
def commands_target_type
|
||||
params[:slash_commands_target_type]
|
||||
end
|
||||
|
||||
def commands_target_id
|
||||
params[:slash_commands_target_id]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue