提交 fba0e44e 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 350fd8b8
## Actionable Insights
Actionable insights always have a follow-up action that needs to take place as a result of the research observation or data, and a clear recommendation or action associated with it. An actionable insight both defines the insight and clearly calls out the next step. These insights are tracked over time.
#### Link
- [ ] Provide the link to the Dovetail actionable insight you created earlier (this should contain all the essential details)
- [ ] If applicable, link this actionable insight issue back to the original Research Issue in the GitLab UX Research project
#### Assign
- [ ] Assign this issue to the appropriate Product Manager, Product Designer, or UX Researcher
#### Description
- [ ] Provide some brief detials on the actionable insight and the action to take
-------------------------------------------------------------------------------
| | PLEASE COMPLETE THE BELOW |
| ------ | ------ |
| Dovetail link: | (URL goes here) |
| Details: | (details go here) |
| Action to take: | (action goes here) |
~"Actionable Insight"
<script>
import { mapState, mapActions } from 'vuex';
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
......@@ -9,8 +9,17 @@ import FormFieldContainer from './form_field_container.vue';
export default {
name: 'TagFieldNew',
components: { GlFormGroup, GlFormInput, RefSelector, FormFieldContainer },
data() {
return {
// Keeps track of whether or not the user has interacted with
// the input field. This is used to avoid showing validation
// errors immediately when the page loads.
isInputDirty: false,
};
},
computed: {
...mapState('detail', ['projectId', 'release', 'createFrom']),
...mapGetters('detail', ['validationErrors']),
tagName: {
get() {
return this.release.tagName;
......@@ -27,6 +36,9 @@ export default {
this.updateCreateFrom(createFrom);
},
},
showTagNameValidationError() {
return this.isInputDirty && this.validationErrors.isTagNameEmpty;
},
tagNameInputId() {
return uniqueId('tag-name-input-');
},
......@@ -36,6 +48,9 @@ export default {
},
methods: {
...mapActions('detail', ['updateReleaseTagName', 'updateCreateFrom']),
markInputAsDirty() {
this.isInputDirty = true;
},
},
translations: {
noRefSelected: __('No source selected'),
......@@ -46,9 +61,22 @@ export default {
</script>
<template>
<div>
<gl-form-group :label="__('Tag name')" :label-for="tagNameInputId" data-testid="tag-name-field">
<gl-form-group
:label="__('Tag name')"
:label-for="tagNameInputId"
data-testid="tag-name-field"
:state="!showTagNameValidationError"
:invalid-feedback="__('Tag name is required')"
>
<form-field-container>
<gl-form-input :id="tagNameInputId" v-model="tagName" type="text" class="form-control" />
<gl-form-input
:id="tagNameInputId"
v-model="tagName"
:state="!showTagNameValidationError"
type="text"
class="form-control"
@blur.once="markInputAsDirty"
/>
</form-field-container>
</gl-form-group>
<gl-form-group
......
......@@ -47,6 +47,10 @@ export const validationErrors = state => {
return errors;
}
if (!state.release.tagName?.trim?.().length) {
errors.isTagNameEmpty = true;
}
// Each key of this object is a URL, and the value is an
// array of Release link objects that share this URL.
// This is used for detecting duplicate URLs.
......@@ -96,5 +100,6 @@ export const validationErrors = state => {
/** Returns whether or not the release object is valid */
export const isValid = (_state, getters) => {
return Object.values(getters.validationErrors.assets.links).every(isEmpty);
const errors = getters.validationErrors;
return Object.values(errors.assets.links).every(isEmpty) && !errors.isTagNameEmpty;
};
......@@ -62,7 +62,11 @@ class RegistrationsController < Devise::RegistrationsController
result = ::Users::SignupService.new(current_user, user_params).execute
if result[:status] == :success
track_experiment_event(:onboarding_issues, 'signed_up') if ::Gitlab.com? && show_onboarding_issues_experiment?
if ::Gitlab.com? && show_onboarding_issues_experiment?
track_experiment_event(:onboarding_issues, 'signed_up')
record_experiment_user(:onboarding_issues)
end
return redirect_to new_users_sign_up_group_path if experiment_enabled?(:onboarding_issues) && show_onboarding_issues_experiment?
set_flash_message! :notice, :signed_up
......
......@@ -6,6 +6,8 @@ class Blob < SimpleDelegator
include BlobLanguageFromGitAttributes
include BlobActiveModel
MODE_SYMLINK = '120000' # The STRING 120000 is the git-reported octal filemode for a symlink
CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute
CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour
......
# frozen_string_literal: true
class Experiment < ApplicationRecord
has_many :experiment_users
has_many :users, through: :experiment_users
has_many :control_group_users, -> { merge(ExperimentUser.control) }, through: :experiment_users, source: :user
has_many :experimental_group_users, -> { merge(ExperimentUser.experimental) }, through: :experiment_users, source: :user
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
def self.add_user(name, group_type, user)
experiment = find_or_create_by(name: name)
return unless experiment
return if experiment.experiment_users.where(user: user).exists?
group_type == ::Gitlab::Experimentation::GROUP_CONTROL ? experiment.add_control_user(user) : experiment.add_experimental_user(user)
end
def add_control_user(user)
control_group_users << user
end
def add_experimental_user(user)
experimental_group_users << user
end
end
# frozen_string_literal: true
class ExperimentUser < ApplicationRecord
belongs_to :experiment
belongs_to :user
enum group_type: { control: 0, experimental: 1 }
validates :group_type, presence: true
end
......@@ -7,6 +7,8 @@
= copy_file_path_button(blob.path)
%small.mr-1
- if blob.mode == Blob::MODE_SYMLINK
= _('Symbolic link') << ' ·'
= number_to_human_size(blob.raw_size)
- if blob.stored_externally? && blob.external_storage == :lfs
......
---
title: Add experiments and experiment_users tables for tracking which users are enrolled for which experiments.
merge_request: 38397
author:
type: added
---
title: Add symlink label text to blob viewer
merge_request: 38220
author:
type: added
---
title: Add expire_at to PipelineArtifact
merge_request: 39114
author:
type: fixed
# frozen_string_literal: true
class CreateExperiment < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
unless table_exists?(:experiments)
create_table :experiments do |t|
t.text :name, null: false
t.index :name, unique: true
end
end
add_text_limit :experiments, :name, 255
end
def down
drop_table :experiments
end
end
# frozen_string_literal: true
class CreateExperimentUser < ActiveRecord::Migration[6.0]
DOWNTIME = false
def up
create_table :experiment_users do |t|
t.bigint :experiment_id, null: false
t.bigint :user_id, null: false
t.integer :group_type, limit: 2, null: false, default: 0
t.timestamps_with_timezone null: false
end
add_index :experiment_users, :experiment_id
add_index :experiment_users, :user_id
end
def down
drop_table :experiment_users
end
end
# frozen_string_literal: true
class AddForeignKeyToExperimentOnExperimentUsers < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
# There is no need to use add_concurrent_foreign_key since it's an empty table
add_foreign_key :experiment_users, :experiments, column: :experiment_id, on_delete: :cascade # rubocop:disable Migration/AddConcurrentForeignKey
end
end
def down
with_lock_retries do
remove_foreign_key :experiment_users, column: :experiment_id
end
end
end
# frozen_string_literal: true
class AddForeignKeyToUserOnExperimentUsers < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
# There is no need to use add_concurrent_foreign_key since it's an empty table
add_foreign_key :experiment_users, :users, column: :user_id, on_delete: :cascade # rubocop:disable Migration/AddConcurrentForeignKey
end
end
def down
with_lock_retries do
remove_foreign_key :experiment_users, column: :user_id
end
end
end
# frozen_string_literal: true
class AddExpireAtToCiPipelineArtifact < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :ci_pipeline_artifacts, :expire_at, :datetime_with_timezone
end
end
aeeef4762f7ab7c7eefc28995ba198825455a43db38c1ff2aefad8c3c76b5fba
\ No newline at end of file
c5775e8150285927b74c2fbf6098d03920e7bea52c3b94ad372fec288835110c
\ No newline at end of file
02f3561a5b8fa79c58dccc23ebaecf2c1b8371c86bb360156456bd11e84d13a2
\ No newline at end of file
26aa28efa15ebd223feb879d5cb684a53a92bc71e483e8348d0cd9f1ad10e7ae
\ No newline at end of file
d459b160ae86f035509e382c12b76fdd441f58e0838a983471326a750a48e9fd
\ No newline at end of file
......@@ -10058,6 +10058,7 @@ CREATE TABLE public.ci_pipeline_artifacts (
file_type smallint NOT NULL,
file_format smallint NOT NULL,
file text,
expire_at timestamp with time zone,
CONSTRAINT check_191b5850ec CHECK ((char_length(file) <= 255))
);
......@@ -11557,6 +11558,39 @@ CREATE SEQUENCE public.evidences_id_seq
ALTER SEQUENCE public.evidences_id_seq OWNED BY public.evidences.id;
CREATE TABLE public.experiment_users (
id bigint NOT NULL,
experiment_id bigint NOT NULL,
user_id bigint NOT NULL,
group_type smallint DEFAULT 0 NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
CREATE SEQUENCE public.experiment_users_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.experiment_users_id_seq OWNED BY public.experiment_users.id;
CREATE TABLE public.experiments (
id bigint NOT NULL,
name text NOT NULL,
CONSTRAINT check_e2dda25ed0 CHECK ((char_length(name) <= 255))
);
CREATE SEQUENCE public.experiments_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.experiments_id_seq OWNED BY public.experiments.id;
CREATE TABLE public.external_pull_requests (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
......@@ -16874,6 +16908,10 @@ ALTER TABLE ONLY public.events ALTER COLUMN id SET DEFAULT nextval('public.event
ALTER TABLE ONLY public.evidences ALTER COLUMN id SET DEFAULT nextval('public.evidences_id_seq'::regclass);
ALTER TABLE ONLY public.experiment_users ALTER COLUMN id SET DEFAULT nextval('public.experiment_users_id_seq'::regclass);
ALTER TABLE ONLY public.experiments ALTER COLUMN id SET DEFAULT nextval('public.experiments_id_seq'::regclass);
ALTER TABLE ONLY public.external_pull_requests ALTER COLUMN id SET DEFAULT nextval('public.external_pull_requests_id_seq'::regclass);
ALTER TABLE ONLY public.feature_gates ALTER COLUMN id SET DEFAULT nextval('public.feature_gates_id_seq'::regclass);
......@@ -17892,6 +17930,12 @@ ALTER TABLE ONLY public.events
ALTER TABLE ONLY public.evidences
ADD CONSTRAINT evidences_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.experiment_users
ADD CONSTRAINT experiment_users_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.experiments
ADD CONSTRAINT experiments_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.external_pull_requests
ADD CONSTRAINT external_pull_requests_pkey PRIMARY KEY (id);
......@@ -19534,6 +19578,12 @@ CREATE UNIQUE INDEX index_events_on_target_type_and_target_id_and_fingerprint ON
CREATE INDEX index_evidences_on_release_id ON public.evidences USING btree (release_id);
CREATE INDEX index_experiment_users_on_experiment_id ON public.experiment_users USING btree (experiment_id);
CREATE INDEX index_experiment_users_on_user_id ON public.experiment_users USING btree (user_id);
CREATE UNIQUE INDEX index_experiments_on_name ON public.experiments USING btree (name);
CREATE INDEX index_expired_and_not_notified_personal_access_tokens ON public.personal_access_tokens USING btree (id, expires_at) WHERE ((impersonation = false) AND (revoked = false) AND (expire_notification_delivered = false));
CREATE UNIQUE INDEX index_external_pull_requests_on_project_and_branches ON public.external_pull_requests USING btree (project_id, source_branch, target_branch);
......@@ -22249,6 +22299,9 @@ ALTER TABLE ONLY public.terraform_states
ALTER TABLE ONLY public.group_deploy_keys
ADD CONSTRAINT fk_rails_5682fc07f8 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE ONLY public.experiment_users
ADD CONSTRAINT fk_rails_56d4708b4a FOREIGN KEY (experiment_id) REFERENCES public.experiments(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.issue_user_mentions
ADD CONSTRAINT fk_rails_57581fda73 FOREIGN KEY (issue_id) REFERENCES public.issues(id) ON DELETE CASCADE;
......@@ -23011,6 +23064,9 @@ ALTER TABLE ONLY public.ci_job_variables
ALTER TABLE ONLY public.packages_nuget_metadata
ADD CONSTRAINT fk_rails_fc0c19f5b4 FOREIGN KEY (package_id) REFERENCES public.packages_packages(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.experiment_users
ADD CONSTRAINT fk_rails_fd805f771a FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.cluster_groups
ADD CONSTRAINT fk_rails_fdb8648a96 FOREIGN KEY (cluster_id) REFERENCES public.clusters(id) ON DELETE CASCADE;
......
......@@ -992,6 +992,21 @@ enum BlobViewersType {
Represents a project or group board
"""
type Board {
"""
The board assignee.
"""
assignee: User
"""
Whether or not backlog list is hidden.
"""
hideBacklogList: Boolean
"""
Whether or not closed list is hidden.
"""
hideClosedList: Boolean
"""
ID (global ID) of the board
"""
......@@ -1027,13 +1042,18 @@ type Board {
last: Int
): BoardListConnection
"""
The board milestone.
"""
milestone: Milestone
"""
Name of the board
"""
name: String
"""
Weight of the board
Weight of the board.
"""
weight: Int
}
......@@ -9075,6 +9095,7 @@ type Mutation {
todosMarkAllDone(input: TodosMarkAllDoneInput!): TodosMarkAllDonePayload
toggleAwardEmoji(input: ToggleAwardEmojiInput!): ToggleAwardEmojiPayload @deprecated(reason: "Use awardEmojiToggle. Deprecated in 13.2")
updateAlertStatus(input: UpdateAlertStatusInput!): UpdateAlertStatusPayload
updateBoard(input: UpdateBoardInput!): UpdateBoardPayload
updateContainerExpirationPolicy(input: UpdateContainerExpirationPolicyInput!): UpdateContainerExpirationPolicyPayload
updateEpic(input: UpdateEpicInput!): UpdateEpicPayload
......@@ -14892,6 +14913,71 @@ type UpdateAlertStatusPayload {
todo: Todo
}
"""
Autogenerated input type of UpdateBoard
"""
input UpdateBoardInput {
"""
The id of user to be assigned to the board.
"""
assigneeId: ID
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Whether or not backlog list is hidden.
"""
hideBacklogList: Boolean
"""
Whether or not closed list is hidden.
"""
hideClosedList: Boolean
"""
The board global id.
"""
id: ID!
"""
The id of milestone to be assigned to the board.
"""
milestoneId: ID
"""
Name of the board
"""
name: String
"""
The weight value to be assigned to the board.
"""
weight: Int
}
"""
Autogenerated return type of UpdateBoard
"""
type UpdateBoardPayload {
"""
The board after mutation.
"""
board: Board
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
"""
Autogenerated input type of UpdateContainerExpirationPolicy
"""
......
......@@ -2661,6 +2661,48 @@
"name": "Board",
"description": "Represents a project or group board",
"fields": [
{
"name": "assignee",
"description": "The board assignee.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "User",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "hideBacklogList",
"description": "Whether or not backlog list is hidden.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "hideClosedList",
"description": "Whether or not closed list is hidden.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID (global ID) of the board",
......@@ -2742,6 +2784,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "milestone",
"description": "The board milestone.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Milestone",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Name of the board",
......@@ -2758,7 +2814,7 @@
},
{
"name": "weight",
"description": "Weight of the board",
"description": "Weight of the board.",
"args": [
],
......@@ -27030,6 +27086,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateBoard",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "UpdateBoardInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "UpdateBoardPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateContainerExpirationPolicy",
"description": null,
......@@ -44014,6 +44097,168 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "UpdateBoardInput",
"description": "Autogenerated input type of UpdateBoard",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "The board global id.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "name",
"description": "Name of the board",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "hideBacklogList",
"description": "Whether or not backlog list is hidden.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null
},
{
"name": "hideClosedList",
"description": "Whether or not closed list is hidden.",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null
},
{
"name": "assigneeId",
"description": "The id of user to be assigned to the board.",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "milestoneId",
"description": "The id of milestone to be assigned to the board.",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "weight",
"description": "The weight value to be assigned to the board.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "UpdateBoardPayload",
"description": "Autogenerated return type of UpdateBoard",
"fields": [
{
"name": "board",
"description": "The board after mutation.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Board",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "UpdateContainerExpirationPolicyInput",
......@@ -188,9 +188,13 @@ Represents a project or group board
| Name | Type | Description |
| --- | ---- | ---------- |
| `assignee` | User | The board assignee. |
| `hideBacklogList` | Boolean | Whether or not backlog list is hidden. |
| `hideClosedList` | Boolean | Whether or not closed list is hidden. |
| `id` | ID! | ID (global ID) of the board |
| `milestone` | Milestone | The board milestone. |
| `name` | String | Name of the board |
| `weight` | Int | Weight of the board |
| `weight` | Int | Weight of the board. |
## BoardList
......@@ -2236,6 +2240,16 @@ Autogenerated return type of UpdateAlertStatus
| `issue` | Issue | The issue created after mutation |
| `todo` | Todo | The todo after mutation |
## UpdateBoardPayload
Autogenerated return type of UpdateBoard
| Name | Type | Description |
| --- | ---- | ---------- |
| `board` | Board | The board after mutation. |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
## UpdateContainerExpirationPolicyPayload
Autogenerated return type of UpdateContainerExpirationPolicy
......
......@@ -5,7 +5,8 @@ almost everything to fit your needs. Auto DevOps offers everything from custom
[buildpacks](#custom-buildpacks), to [Dockerfiles](#custom-dockerfile), and
[Helm charts](#custom-helm-chart). You can even copy the complete
[CI/CD configuration](#customizing-gitlab-ciyml) into your project to enable
staging and canary deployments, and more.
staging and canary deployments,
[manage Auto DevOps with GitLab APIs](customize.md#extend-auto-devops-with-the-api), and more.
## Custom buildpacks
......@@ -77,6 +78,16 @@ Avoid passing secrets as Docker build arguments if possible, as they may be
persisted in your image. See
[this discussion of best practices with secrets](https://github.com/moby/moby/issues/13490) for details.
## Extend Auto DevOps with the API
You can extend and manage your Auto DevOps configuration with GitLab APIs:
- [Settings that can be accessed with API calls](../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls),
which include `auto_devops_enabled`, to enable Auto DevOps on projects by default.
- [Creating a new project](../../api/projects.md#create-project).
- [Editing groups](../../api/groups.md#update-group).
- [Editing projects](../../api/projects.md#edit-project).
## Forward CI variables to the build environment
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/25514) in GitLab 12.3, but available in versions 11.9 and above.
......
......@@ -104,8 +104,10 @@ knowledge of the following:
- [GitLab Runner](https://docs.gitlab.com/runner/)
- [Prometheus](https://prometheus.io/docs/introduction/overview/)
Auto DevOps provides great defaults for all the stages and makes use of [CI templates](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates); you can, however,
[customize](customize.md) almost everything to your needs.
Auto DevOps provides great defaults for all the stages and makes use of
[CI templates](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates). You can, however,
[customize](customize.md) almost everything to your needs, and
[manage Auto DevOps with GitLab APIs](customize.md#extend-auto-devops-with-the-api).
For an overview on the creation of Auto DevOps, read more
[in this blog post](https://about.gitlab.com/blog/2017/06/29/whats-next-for-gitlab-ci/).
......
......@@ -201,10 +201,7 @@ Before diving into the details, some things you should be aware of:
### Authenticating to the Container Registry with GitLab CI/CD
There are three ways to authenticate to the Container Registry via
[GitLab CI/CD](../../../ci/yaml/README.md) which depend on the visibility of
your project.
Available for all projects, though more suitable for public ones:
[GitLab CI/CD](../../../ci/yaml/README.md):
- **Using the special `CI_REGISTRY_USER` variable**: The user specified by this variable is created for you in order to
push to the Registry connected to your project. Its password is automatically
......@@ -216,14 +213,22 @@ Available for all projects, though more suitable for public ones:
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
```
For private and internal projects:
- **Using the GitLab Deploy Token**: You can create and use a
[special deploy token](../../project/deploy_tokens/index.md#gitlab-deploy-token)
with your projects.
Once created, you can use the special environment variables, and GitLab CI/CD
fills them in for you. You can use the following example as-is:
```shell
docker login -u $CI_DEPLOY_USER -p $CI_DEPLOY_PASSWORD $CI_REGISTRY
```
- **Using a personal access token**: You can create and use a
[personal access token](../../profile/personal_access_tokens.md)
in case your project is private:
- For read (pull) access, the scope should be `read_registry`.
- For read/write (pull/push) access, use `api`.
- For write (push) access, the scope should be `write_registry`.
Replace the `<username>` and `<access_token>` in the following example:
......@@ -231,16 +236,6 @@ For private and internal projects:
docker login -u <username> -p <access_token> $CI_REGISTRY
```
- **Using the GitLab Deploy Token**: You can create and use a
[special deploy token](../../project/deploy_tokens/index.md#gitlab-deploy-token)
with your private projects. It provides read-only (pull) access to the Registry.
Once created, you can use the special environment variables, and GitLab CI/CD
fills them in for you. You can use the following example as-is:
```shell
docker login -u $CI_DEPLOY_USER -p $CI_DEPLOY_PASSWORD $CI_REGISTRY
```
### Container Registry examples with GitLab CI/CD
If you're using Docker-in-Docker on your Runners, this is how your `.gitlab-ci.yml`
......
......@@ -60,6 +60,7 @@ the following table.
| `api` | [GitLab 8.15](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/5951) | Grants complete read/write access to the API, including all groups and projects, the container registry, and the package registry. |
| `read_api` | [GitLab 12.10](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28944) | Grants read access to the API, including all groups and projects, the container registry, and the package registry. |
| `read_registry` | [GitLab 9.3](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/11845) | Allows to read (pull) [container registry](../packages/container_registry/index.md) images if a project is private and authorization is required. |
| `write_registry` | [GitLab 12.10](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28958) | Allows to write (push) [container registry](../packages/container_registry/index.md) images if a project is private and authorization is required. |
| `sudo` | [GitLab 10.2](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/14838) | Allows performing API actions as any user in the system (if the authenticated user is an administrator). |
| `read_repository` | [GitLab 10.7](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/17894) | Allows read-only access (pull) to the repository through `git clone`. |
| `write_repository` | [GitLab 11.11](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/26021) | Allows read-write access (pull, push) to the repository through `git clone`. Required for accessing Git repositories over HTTP when 2FA is enabled. |
......
......@@ -53,7 +53,7 @@ the bottom-left corner of its pages:
![Edit this page button](img/edit_this_page_button_v12_10.png)
When clicking it, GitLab will open up an editor window from which the content
When you click it, GitLab opens up an editor window from which the content
can be directly edited. When you're ready, you can submit your changes in a
click of a button:
......
......@@ -62,6 +62,9 @@ module Gitlab
}
}.freeze
GROUP_CONTROL = :control
GROUP_EXPERIMENTAL = :experimental
# Controller concern that checks if an `experimentation_subject_id cookie` is present and sets it if absent.
# Used for A/B testing of experimental features. Exposes the `experiment_enabled?(experiment_name)` method
# to controllers and views. It returns true when the experiment is enabled and the user is selected as part
......@@ -106,6 +109,12 @@ module Gitlab
end
end
def record_experiment_user(experiment_key)
return unless Experimentation.enabled?(experiment_key) && current_user
::Experiment.add_user(experiment_key, tracking_group(experiment_key), current_user)
end
private
def dnt_enabled?
......@@ -132,7 +141,7 @@ module Gitlab
{
category: tracking_category(experiment_key),
action: action,
property: tracking_group(experiment_key),
property: "#{tracking_group(experiment_key)}_group",
label: experimentation_subject_id,
value: value
}.compact
......@@ -145,7 +154,7 @@ module Gitlab
def tracking_group(experiment_key)
return unless Experimentation.enabled?(experiment_key)
experiment_enabled?(experiment_key) ? 'experimental_group' : 'control_group'
experiment_enabled?(experiment_key) ? GROUP_EXPERIMENTAL : GROUP_CONTROL
end
def forced_enabled?(experiment_key)
......
......@@ -23629,6 +23629,9 @@ msgstr ""
msgid "Switch to the source to copy the file contents"
msgstr ""
msgid "Symbolic link"
msgstr ""
msgid "Sync information"
msgstr ""
......@@ -23677,6 +23680,9 @@ msgstr ""
msgid "Tag name"
msgstr ""
msgid "Tag name is required"
msgstr ""
msgid "Tag this commit."
msgstr ""
......
# frozen_string_literal: true
FactoryBot.define do
factory :experiment do
name { generate(:title) }
end
end
import { shallowMount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import { GlFormInput } from '@gitlab/ui';
import TagFieldNew from '~/releases/components/tag_field_new.vue';
import createStore from '~/releases/stores';
......@@ -16,6 +16,9 @@ describe('releases/components/tag_field_new', () => {
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(TagFieldNew, {
store,
stubs: {
RefSelector: true,
},
});
};
......@@ -32,6 +35,9 @@ describe('releases/components/tag_field_new', () => {
store.state.detail.release = {
tagName: TEST_TAG_NAME,
assets: {
links: [],
},
};
});
......@@ -42,24 +48,76 @@ describe('releases/components/tag_field_new', () => {
const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]');
const findTagNameGlInput = () => findTagNameFormGroup().find(GlFormInput);
const findTagNameInput = () => findTagNameFormGroup().find('input');
const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]');
const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelector);
describe('"Tag name" field', () => {
beforeEach(createComponent);
describe('rendering and behavior', () => {
beforeEach(createComponent);
it('renders a label', () => {
expect(findTagNameFormGroup().attributes().label).toBe('Tag name');
it('renders a label', () => {
expect(findTagNameFormGroup().attributes().label).toBe('Tag name');
});
describe('when the user updates the field', () => {
it("updates the store's release.tagName property", () => {
const updatedTagName = 'updated-tag-name';
findTagNameGlInput().vm.$emit('input', updatedTagName);
return wrapper.vm.$nextTick().then(() => {
expect(store.state.detail.release.tagName).toBe(updatedTagName);
});
});
});
});
describe('when the user updates the field', () => {
it("updates the store's release.tagName property", () => {
const updatedTagName = 'updated-tag-name';
findTagNameGlInput().vm.$emit('input', updatedTagName);
describe('validation', () => {
beforeEach(() => {
createComponent(mount);
});
/**
* Utility function to test the visibility of the validation message
* @param {'shown' | 'hidden'} state The expected state of the validation message.
* Should be passed either 'shown' or 'hidden'
*/
const expectValidationMessageToBe = state => {
return wrapper.vm.$nextTick().then(() => {
expect(store.state.detail.release.tagName).toBe(updatedTagName);
expect(findTagNameFormGroup().element).toHaveClass(
state === 'shown' ? 'is-invalid' : 'is-valid',
);
expect(findTagNameFormGroup().element).not.toHaveClass(
state === 'shown' ? 'is-valid' : 'is-invalid',
);
});
};
describe('when the user has not yet interacted with the component', () => {
it('does not display a validation error', () => {
findTagNameInput().setValue('');
return expectValidationMessageToBe('hidden');
});
});
describe('when the user has interacted with the component and the value is not empty', () => {
it('does not display validation error', () => {
findTagNameInput().trigger('blur');
return expectValidationMessageToBe('hidden');
});
});
describe('when the user has interacted with the component and the value is empty', () => {
it('displays a validation error', () => {
const tagNameInput = findTagNameInput();
tagNameInput.setValue('');
tagNameInput.trigger('blur');
return expectValidationMessageToBe('shown');
});
});
});
......
......@@ -76,6 +76,7 @@ describe('Release detail getters', () => {
it('returns no validation errors', () => {
const state = {
release: {
tagName: 'test-tag-name',
assets: {
links: [
{ id: 1, url: 'https://example.com/valid', name: 'Link 1' },
......@@ -110,6 +111,9 @@ describe('Release detail getters', () => {
beforeEach(() => {
const state = {
release: {
// empty tag name
tagName: '',
assets: {
links: [
// Duplicate URLs
......@@ -138,7 +142,15 @@ describe('Release detail getters', () => {
actualErrors = getters.validationErrors(state);
});
it('returns a validation errors if links share a URL', () => {
it('returns a validation error if the tag name is empty', () => {
const expectedErrors = {
isTagNameEmpty: true,
};
expect(actualErrors).toMatchObject(expectedErrors);
});
it('returns a validation error if links share a URL', () => {
const expectedErrors = {
assets: {
links: {
......@@ -196,32 +208,53 @@ describe('Release detail getters', () => {
// the value of state is not actually used by this getter
const state = {};
it('returns true when the form is valid', () => {
const mockGetters = {
validationErrors: {
assets: {
links: {
1: {},
describe('when the form is valid', () => {
it('returns true', () => {
const mockGetters = {
validationErrors: {
assets: {
links: {
1: {},
},
},
},
},
};
};
expect(getters.isValid(state, mockGetters)).toBe(true);
expect(getters.isValid(state, mockGetters)).toBe(true);
});
});
it('returns false when the form is invalid', () => {
const mockGetters = {
validationErrors: {
assets: {
links: {
1: { isNameEmpty: true },
describe('when an asset link contains a validation error', () => {
it('returns false', () => {
const mockGetters = {
validationErrors: {
assets: {
links: {
1: { isNameEmpty: true },
},
},
},
},
};
};
expect(getters.isValid(state, mockGetters)).toBe(false);
expect(getters.isValid(state, mockGetters)).toBe(false);
});
});
describe('when the tag name is empty', () => {
it('returns false', () => {
const mockGetters = {
validationErrors: {
isTagNameEmpty: true,
assets: {
links: {
1: {},
},
},
},
};
expect(getters.isValid(state, mockGetters)).toBe(false);
});
});
});
});
......@@ -233,6 +233,68 @@ RSpec.describe Gitlab::Experimentation do
end
end
end
describe '#record_experiment_user' do
let(:user) { build(:user) }
context 'when the experiment is enabled' do
before do
stub_experiment(test_experiment: true)
allow(controller).to receive(:current_user).and_return(user)
end
context 'the user is part of the experimental group' do
before do
stub_experiment_for_user(test_experiment: true)
end
it 'calls add_user on the Experiment model' do
expect(::Experiment).to receive(:add_user).with(:test_experiment, :experimental, user)
controller.record_experiment_user(:test_experiment)
end
end
context 'the user is part of the control group' do
before do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
end
end
it 'calls add_user on the Experiment model' do
expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user)
controller.record_experiment_user(:test_experiment)
end
end
end
context 'when the experiment is disabled' do
before do
stub_experiment(test_experiment: false)
allow(controller).to receive(:current_user).and_return(user)
end
it 'does not call add_user on the Experiment model' do
expect(::Experiment).not_to receive(:add_user)
controller.record_experiment_user(:test_experiment)
end
end
context 'when there is no current_user' do
before do
stub_experiment(test_experiment: true)
end
it 'does not call add_user on the Experiment model' do
expect(::Experiment).not_to receive(:add_user)
controller.record_experiment_user(:test_experiment)
end
end
end
end
describe '.enabled?' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Experiment do
subject { build(:experiment) }
describe 'associations' do
it { is_expected.to have_many(:experiment_users) }
it { is_expected.to have_many(:users) }
it { is_expected.to have_many(:control_group_users) }
it { is_expected.to have_many(:experimental_group_users) }
describe 'control_group_users and experimental_group_users' do
let(:experiment) { create(:experiment) }
let(:control_group_user) { build(:user) }
let(:experimental_group_user) { build(:user) }
before do
experiment.control_group_users << control_group_user
experiment.experimental_group_users << experimental_group_user
end
describe 'control_group_users' do
subject { experiment.control_group_users }
it { is_expected.to contain_exactly(control_group_user) }
end
describe 'experimental_group_users' do
subject { experiment.experimental_group_users }
it { is_expected.to contain_exactly(experimental_group_user) }
end
end
end
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
end
describe '.add_user' do
let(:name) { :experiment_key }
let(:user) { build(:user) }
let!(:experiment) { create(:experiment, name: name) }
subject { described_class.add_user(name, :control, user) }
describe 'creating a new experiment record' do
context 'an experiment with the provided name already exists' do
it 'does not create a new experiment record' do
expect { subject }.not_to change(Experiment, :count)
end
end
context 'an experiment with the provided name does not exist yet' do
let(:experiment) { nil }
it 'creates a new experiment record' do
expect { subject }.to change(Experiment, :count).by(1)
end
end
end
describe 'creating a new experiment_user record' do
context 'an experiment_user record for this experiment already exists' do
before do
subject
end
it 'does not create a new experiment_user record' do
expect { subject }.not_to change(ExperimentUser, :count)
end
end
context 'an experiment_user record for this experiment does not exist yet' do
it 'creates a new experiment_user record' do
expect { subject }.to change(ExperimentUser, :count).by(1)
end
it 'assigns the correct group_type to the experiment_user' do
expect { subject }.to change { experiment.control_group_users.count }.by(1)
end
end
end
end
describe '#add_control_user' do
let(:experiment) { create(:experiment) }
let(:user) { build(:user) }
subject { experiment.add_control_user(user) }
it 'creates a new experiment_user record and assigns the correct group_type' do
expect { subject }.to change { experiment.control_group_users.count }.by(1)
end
end
describe '#add_experimental_user' do
let(:experiment) { create(:experiment) }
let(:user) { build(:user) }
subject { experiment.add_experimental_user(user) }
it 'creates a new experiment_user record and assigns the correct group_type' do
expect { subject }.to change { experiment.experimental_group_users.count }.by(1)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ExperimentUser do
describe 'Associations' do
it { is_expected.to belong_to(:experiment) }
it { is_expected.to belong_to(:user) }
end
describe 'Validations' do
it { is_expected.to validate_presence_of(:group_type) }
end
end
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册