Commit ecd85ae5 authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch 'zj-show-deployed-time-ago' into 'master'

Show deployed time ago

Closes #21652

cc @ayufan 

See merge request !6676
parents 2a0cccc1 cf15af31
...@@ -51,6 +51,7 @@ v 8.13.0 (unreleased) ...@@ -51,6 +51,7 @@ v 8.13.0 (unreleased)
- Add new issue button to each list on Issues Board - Add new issue button to each list on Issues Board
- Added soft wrap button to repository file/blob editor - Added soft wrap button to repository file/blob editor
- Update namespace validation to forbid reserved names (.git and .atom) (Will Starms) - Update namespace validation to forbid reserved names (.git and .atom) (Will Starms)
- Show the time ago a merge request was deployed to an environment
- Add word-wrap to issue title on issue and milestone boards (ClemMakesApps) - Add word-wrap to issue title on issue and milestone boards (ClemMakesApps)
- Fix todos page mobile viewport layout (ClemMakesApps) - Fix todos page mobile viewport layout (ClemMakesApps)
- Fix inconsistent highlighting of already selected activity nav-links (ClemMakesApps) - Fix inconsistent highlighting of already selected activity nav-links (ClemMakesApps)
......
(function() { ((global) => {
var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
this.MergeRequestWidget = (function() { const DEPLOYMENT_TEMPLATE = `<div class="mr-widget-heading" id="<%- id %>">
<div class="ci_widget ci-success">
<%= ci_success_icon %>
<span>
Deployed to
<a href="<%- url %>" target="_blank" class="environment">
<%- name %>
</a>
<span class="js-environment-timeago" data-toggle="tooltip" data-placement="top" data-title="<%- deployed_at_formatted %>">
<%- deployed_at %>
</span>
<a class="js-environment-link" href="<%- external_url %>" target="_blank">
<i class="fa fa-external-link"></i>
View on <%- external_url_formatted %>
</a>
</span>
</div>
</div>`;
global.MergeRequestWidget = (function() {
function MergeRequestWidget(opts) { function MergeRequestWidget(opts) {
// Initialize MergeRequestWidget behavior // Initialize MergeRequestWidget behavior
// //
...@@ -10,17 +29,23 @@ ...@@ -10,17 +29,23 @@
// ci_status_url - String, URL to use to check CI status // ci_status_url - String, URL to use to check CI status
// //
this.opts = opts; this.opts = opts;
this.$widgetBody = $('.mr-widget-body');
$('#modal_merge_info').modal({ $('#modal_merge_info').modal({
show: false show: false
}); });
this.firstCICheck = true; this.firstCICheck = true;
this.readyForCICheck = false; this.readyForCICheck = false;
this.readyForCIEnvironmentCheck = false;
this.cancel = false; this.cancel = false;
clearInterval(this.fetchBuildStatusInterval); clearInterval(this.fetchBuildStatusInterval);
clearInterval(this.fetchBuildEnvironmentStatusInterval);
this.clearEventListeners(); this.clearEventListeners();
this.addEventListeners(); this.addEventListeners();
this.getCIStatus(false); this.getCIStatus(false);
this.getCIEnvironmentsStatus();
this.retrieveSuccessIcon();
this.pollCIStatus(); this.pollCIStatus();
this.pollCIEnvironmentsStatus();
notifyPermissions(); notifyPermissions();
} }
...@@ -41,6 +66,7 @@ ...@@ -41,6 +66,7 @@
page = $('body').data('page').split(':').last(); page = $('body').data('page').split(':').last();
if (allowedPages.indexOf(page) < 0) { if (allowedPages.indexOf(page) < 0) {
clearInterval(_this.fetchBuildStatusInterval); clearInterval(_this.fetchBuildStatusInterval);
clearInterval(_this.fetchBuildEnvironmentStatusInterval);
_this.cancelPolling(); _this.cancelPolling();
return _this.clearEventListeners(); return _this.clearEventListeners();
} }
...@@ -48,6 +74,12 @@ ...@@ -48,6 +74,12 @@
})(this)); })(this));
}; };
MergeRequestWidget.prototype.retrieveSuccessIcon = function() {
const $ciSuccessIcon = $('.js-success-icon');
this.$ciSuccessIcon = $ciSuccessIcon.html();
$ciSuccessIcon.remove();
}
MergeRequestWidget.prototype.mergeInProgress = function(deleteSourceBranch) { MergeRequestWidget.prototype.mergeInProgress = function(deleteSourceBranch) {
if (deleteSourceBranch == null) { if (deleteSourceBranch == null) {
deleteSourceBranch = false; deleteSourceBranch = false;
...@@ -62,7 +94,7 @@ ...@@ -62,7 +94,7 @@
urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : ''; urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
return window.location.href = window.location.pathname + urlSuffix; return window.location.href = window.location.pathname + urlSuffix;
} else if (data.merge_error) { } else if (data.merge_error) {
return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>"); return this.$widgetBody.html("<h4>" + data.merge_error + "</h4>");
} else { } else {
callback = function() { callback = function() {
return merge_request_widget.mergeInProgress(deleteSourceBranch); return merge_request_widget.mergeInProgress(deleteSourceBranch);
...@@ -118,6 +150,7 @@ ...@@ -118,6 +150,7 @@
if (data.status === '') { if (data.status === '') {
return; return;
} }
if (data.environments && data.environments.length) _this.renderEnvironments(data.environments);
if (_this.firstCICheck || data.status !== _this.opts.ci_status && (data.status != null)) { if (_this.firstCICheck || data.status !== _this.opts.ci_status && (data.status != null)) {
_this.opts.ci_status = data.status; _this.opts.ci_status = data.status;
_this.showCIStatus(data.status); _this.showCIStatus(data.status);
...@@ -150,6 +183,41 @@ ...@@ -150,6 +183,41 @@
})(this)); })(this));
}; };
MergeRequestWidget.prototype.pollCIEnvironmentsStatus = function() {
this.fetchBuildEnvironmentStatusInterval = setInterval(() => {
if (!this.readyForCIEnvironmentCheck) return;
this.getCIEnvironmentsStatus();
this.readyForCIEnvironmentCheck = false;
}, 300000);
};
MergeRequestWidget.prototype.getCIEnvironmentsStatus = function() {
$.getJSON(this.opts.ci_environments_status_url, (environments) => {
if (this.cancel) return;
this.readyForCIEnvironmentCheck = true;
if (environments && environments.length) this.renderEnvironments(environments);
});
};
MergeRequestWidget.prototype.renderEnvironments = function(environments) {
for (let i = 0; i < environments.length; i++) {
const environment = environments[i];
if ($(`.mr-state-widget #${ environment.id }`).length) return;
const $template = $(DEPLOYMENT_TEMPLATE);
if (!environment.external_url || !environment.external_url_formatted) $('.js-environment-link', $template).remove();
if (environment.deployed_at && environment.deployed_at_formatted) {
environment.deployed_at = $.timeago(environment.deployed_at) + '.';
} else {
$('.js-environment-timeago', $template).remove();
environment.name += '.';
}
environment.ci_success_icon = this.$ciSuccessIcon;
const templateString = _.unescape($template[0].outerHTML);
const template = _.template(templateString)(environment)
this.$widgetBody.before(template);
}
};
MergeRequestWidget.prototype.showCIStatus = function(state) { MergeRequestWidget.prototype.showCIStatus = function(state) {
var allowed_states; var allowed_states;
if (state == null) { if (state == null) {
...@@ -190,4 +258,4 @@ ...@@ -190,4 +258,4 @@
})(); })();
}).call(this); })(window.gl || (window.gl = {}));
...@@ -121,6 +121,10 @@ ...@@ -121,6 +121,10 @@
color: #5c5d5e; color: #5c5d5e;
} }
.js-deployment-link {
display: inline-block;
}
.mr-widget-body { .mr-widget-body {
h4 { h4 {
font-weight: 600; font-weight: 600;
......
...@@ -10,7 +10,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -10,7 +10,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
before_action :module_enabled before_action :module_enabled
before_action :merge_request, only: [ before_action :merge_request, only: [
:edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check, :edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check,
:ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues :ci_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues
] ]
before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines] before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines]
before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines] before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines]
...@@ -403,6 +403,30 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -403,6 +403,30 @@ class Projects::MergeRequestsController < Projects::ApplicationController
render json: response render json: response
end end
def ci_environments_status
environments =
begin
@merge_request.environments.map do |environment|
next unless can?(current_user, :read_environment, environment)
project = environment.project
deployment = environment.first_deployment_for(@merge_request.diff_head_commit)
{
id: environment.id,
name: environment.name,
url: namespace_project_environment_path(project.namespace, project, environment),
external_url: environment.external_url,
external_url_formatted: environment.formatted_external_url,
deployed_at: deployment.try(:created_at),
deployed_at_formatted: deployment.try(:formatted_deployment_time)
}
end.compact
end
render json: environments
end
protected protected
def selected_target_project def selected_target_project
......
...@@ -84,6 +84,10 @@ class Deployment < ActiveRecord::Base ...@@ -84,6 +84,10 @@ class Deployment < ActiveRecord::Base
take take
end end
def formatted_deployment_time
created_at.to_time.in_time_zone.to_s(:medium)
end
private private
def ref_path def ref_path
......
...@@ -48,7 +48,22 @@ class Environment < ActiveRecord::Base ...@@ -48,7 +48,22 @@ class Environment < ActiveRecord::Base
self.name == "production" self.name == "production"
end end
def first_deployment_for(commit)
ref = project.repository.ref_name_for_sha(ref_path, commit.sha)
return nil unless ref
deployment_id = ref.split('/').last
deployments.find(deployment_id)
end
def ref_path def ref_path
"refs/environments/#{Shellwords.shellescape(name)}" "refs/environments/#{Shellwords.shellescape(name)}"
end end
def formatted_external_url
return nil unless external_url
external_url.gsub(/\A.*?:\/\//, '')
end
end end
...@@ -688,12 +688,15 @@ class MergeRequest < ActiveRecord::Base ...@@ -688,12 +688,15 @@ class MergeRequest < ActiveRecord::Base
def environments def environments
return [] unless diff_head_commit return [] unless diff_head_commit
environments = source_project.environments_for( @environments ||=
source_branch, diff_head_commit) begin
environments += target_project.environments_for( environments = source_project.environments_for(
target_branch, diff_head_commit, with_tags: true) source_branch, diff_head_commit)
environments += target_project.environments_for(
environments.uniq target_branch, diff_head_commit, with_tags: true)
environments.uniq
end
end end
def state_human_name def state_human_name
......
...@@ -719,6 +719,14 @@ class Repository ...@@ -719,6 +719,14 @@ class Repository
end end
end end
def ref_name_for_sha(ref_path, sha)
args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha})
# Not found -> ["", 0]
# Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]
Gitlab::Popen.popen(args, path_to_repo).first.split.last
end
def refs_contains_sha(ref_type, sha) def refs_contains_sha(ref_type, sha)
args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha}) args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha})
names = Gitlab::Popen.popen(args, path_to_repo).first names = Gitlab::Popen.popen(args, path_to_repo).first
......
...@@ -44,17 +44,5 @@ ...@@ -44,17 +44,5 @@
= icon("times-circle") = icon("times-circle")
Could not connect to the CI server. Please check your settings and try again. Could not connect to the CI server. Please check your settings and try again.
- @merge_request.environments.sort_by(&:name).each do |environment| .js-success-icon.hidden
- if can?(current_user, :read_environment, environment) = ci_icon_for_status('success')
.mr-widget-heading
.ci_widget.ci-success
= ci_icon_for_status("success")
%span
Deployed to
= succeed '.' do
= link_to environment.name, environment_path(environment), class: 'environment'
- external_url = environment.external_url
- if external_url
= link_to external_url, target: '_blank' do
%span.hidden-xs View on #{external_url.gsub(/\A.*?:\/\//, '')}
= icon('external-link', right: true)
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
check_enable: #{@merge_request.unchecked? ? "true" : "false"}, check_enable: #{@merge_request.unchecked? ? "true" : "false"},
ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
ci_environments_status_url: "#{ci_environments_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
gitlab_icon: "#{asset_path 'gitlab_logo.png'}", gitlab_icon: "#{asset_path 'gitlab_logo.png'}",
ci_status: "#{@merge_request.pipeline ? @merge_request.pipeline.status : ''}", ci_status: "#{@merge_request.pipeline ? @merge_request.pipeline.status : ''}",
ci_message: { ci_message: {
...@@ -33,4 +34,4 @@ ...@@ -33,4 +34,4 @@
merge_request_widget.clearEventListeners(); merge_request_widget.clearEventListeners();
} }
merge_request_widget = new MergeRequestWidget(opts); merge_request_widget = new window.gl.MergeRequestWidget(opts);
...@@ -273,6 +273,7 @@ resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only: ...@@ -273,6 +273,7 @@ resources :namespaces, path: '/', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only:
post :merge post :merge
post :cancel_merge_when_build_succeeds post :cancel_merge_when_build_succeeds
get :ci_status get :ci_status
get :ci_environments_status
post :toggle_subscription post :toggle_subscription
post :remove_wip post :remove_wip
get :diff_for_path get :diff_for_path
......
...@@ -756,4 +756,34 @@ describe Projects::MergeRequestsController do ...@@ -756,4 +756,34 @@ describe Projects::MergeRequestsController do
post_assign_issues post_assign_issues
end end
end end
describe 'GET ci_environments_status' do
context 'when the environment is from a forked project' do
let!(:forked) { create(:project) }
let!(:environment) { create(:environment, project: forked) }
let!(:deployment) { create(:deployment, environment: environment, sha: forked.commit.id, ref: 'master') }
let(:json_response) { JSON.parse(response.body) }
let(:admin) { create(:admin) }
let(:merge_request) do
create(:forked_project_link, forked_to_project: forked,
forked_from_project: project)
create(:merge_request, source_project: forked, target_project: project)
end
before do
forked.team << [user, :master]
get :ci_environments_status,
namespace_id: merge_request.project.namespace.to_param,
project_id: merge_request.project.to_param,
id: merge_request.iid, format: 'json'
end
it 'links to the environment on that project' do
expect(json_response.first['url']).to match /#{forked.path_with_namespace}/
end
end
end
end end
require 'spec_helper' require 'spec_helper'
describe 'projects/merge_requests/widget/_heading' do feature 'Widget Deployments Header', feature: true, js: true do
include Devise::Test::ControllerHelpers include WaitForAjax
context 'when released to an environment' do describe 'when deployed to an environment' do
let(:project) { merge_request.target_project } let(:project) { merge_request.target_project }
let(:merge_request) { create(:merge_request, :merged) } let(:merge_request) { create(:merge_request, :merged) }
let(:environment) { create(:environment, project: project) } let(:environment) { create(:environment, project: project) }
...@@ -12,17 +12,15 @@ describe 'projects/merge_requests/widget/_heading' do ...@@ -12,17 +12,15 @@ describe 'projects/merge_requests/widget/_heading' do
end end
before do before do
assign(:merge_request, merge_request) login_as :admin
assign(:project, project) visit namespace_project_merge_request_path(project.namespace, project, merge_request)
allow(view).to receive(:can?).and_return(true)
render
end end
it 'displays that the environment is deployed' do it 'displays that the environment is deployed' do
expect(rendered).to match("Deployed to") wait_for_ajax
expect(rendered).to match("#{environment.name}")
expect(page).to have_content("Deployed to #{environment.name}")
expect(find('.ci_widget > span > span')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium))
end end
end end
end end
/*= require merge_request_widget */ /*= require merge_request_widget */
/*= require lib/utils/jquery.timeago.js */
(function() { (function() {
describe('MergeRequestWidget', function() { describe('MergeRequestWidget', function() {
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
window.notify = function() {}; window.notify = function() {};
this.opts = { this.opts = {
ci_status_url: "http://sampledomain.local/ci/getstatus", ci_status_url: "http://sampledomain.local/ci/getstatus",
ci_environments_status_url: "http://sampledomain.local/ci/getenvironmentsstatus",
ci_status: "", ci_status: "",
ci_message: { ci_message: {
normal: "Build {{status}} for \"{{title}}\"", normal: "Build {{status}} for \"{{title}}\"",
...@@ -20,17 +21,48 @@ ...@@ -20,17 +21,48 @@
gitlab_icon: "gitlab_logo.png", gitlab_icon: "gitlab_logo.png",
builds_path: "http://sampledomain.local/sampleBuildsPath" builds_path: "http://sampledomain.local/sampleBuildsPath"
}; };
this["class"] = new MergeRequestWidget(this.opts); this["class"] = new window.gl.MergeRequestWidget(this.opts);
return this.ciStatusData = {
"title": "Sample MR title",
"sha": "12a34bc5",
"status": "success",
"coverage": 98
};
}); });
describe('getCIEnvironmentsStatus', function() {
beforeEach(function() {
this.ciEnvironmentsStatusData = [{
created_at: '2016-09-12T13:38:30.636Z',
environment_id: 1,
environment_name: 'env1',
external_url: 'https://test-url.com',
external_url_formatted: 'test-url.com'
}];
spyOn(jQuery, 'getJSON').and.callFake((req, cb) => {
cb(this.ciEnvironmentsStatusData);
});
});
it('should call renderEnvironments when the environments property is set', function() {
const spy = spyOn(this.class, 'renderEnvironments').and.stub();
this.class.getCIEnvironmentsStatus();
expect(spy).toHaveBeenCalledWith(this.ciEnvironmentsStatusData);
});
it('should not call renderEnvironments when the environments property is not set', function() {
this.ciEnvironmentsStatusData = null;
const spy = spyOn(this.class, 'renderEnvironments').and.stub();
this.class.getCIEnvironmentsStatus();
expect(spy).not.toHaveBeenCalled();
});
});
return describe('getCIStatus', function() { return describe('getCIStatus', function() {
beforeEach(function() { beforeEach(function() {
return spyOn(jQuery, 'getJSON').and.callFake((function(_this) { this.ciStatusData = {
"title": "Sample MR title",
"sha": "12a34bc5",
"status": "success",
"coverage": 98
};
spyOn(jQuery, 'getJSON').and.callFake((function(_this) {
return function(req, cb) { return function(req, cb) {
return cb(_this.ciStatusData); return cb(_this.ciStatusData);
}; };
...@@ -61,10 +93,10 @@ ...@@ -61,10 +93,10 @@
this["class"].getCIStatus(false); this["class"].getCIStatus(false);
return expect(spy).not.toHaveBeenCalled(); return expect(spy).not.toHaveBeenCalled();
}); });
return it('should not display a notification on the first check after the widget has been created', function() { it('should not display a notification on the first check after the widget has been created', function() {
var spy; var spy;
spy = spyOn(window, 'notify'); spy = spyOn(window, 'notify');
this["class"] = new MergeRequestWidget(this.opts); this["class"] = new window.gl.MergeRequestWidget(this.opts);
this["class"].getCIStatus(true); this["class"].getCIStatus(true);
return expect(spy).not.toHaveBeenCalled(); return expect(spy).not.toHaveBeenCalled();
}); });
......
...@@ -64,6 +64,23 @@ describe Environment, models: true do ...@@ -64,6 +64,23 @@ describe Environment, models: true do
end end
end end
describe '#first_deployment_for' do
let(:project) { create(:project) }
let!(:environment) { create(:environment, project: project) }
let!(:deployment) { create(:deployment, environment: environment, ref: commit.parent.id) }
let!(:deployment1) { create(:deployment, environment: environment, ref: commit.id) }
let(:head_commit) { project.commit }
let(:commit) { project.commit.parent }
it 'returns deployment id for the environment' do
expect(environment.first_deployment_for(commit)).to eq deployment1
end
it 'return nil when no deployment is found' do
expect(environment.first_deployment_for(head_commit)).to eq nil
end
end
describe '#environment_type' do describe '#environment_type' do
subject { environment.environment_type } subject { environment.environment_type }
......
...@@ -7,15 +7,18 @@ describe Repository, models: true do ...@@ -7,15 +7,18 @@ describe Repository, models: true do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:repository) { project.repository } let(:repository) { project.repository }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:commit_options) do let(:commit_options) do
author = repository.user_to_committer(user) author = repository.user_to_committer(user)
{ message: 'Test message', committer: author, author: author } { message: 'Test message', committer: author, author: author }
end end
let(:merge_commit) do let(:merge_commit) do
merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project)
merge_commit_id = repository.merge(user, merge_request, commit_options) merge_commit_id = repository.merge(user, merge_request, commit_options)
repository.commit(merge_commit_id) repository.commit(merge_commit_id)
end end
let(:author_email) { FFaker::Internet.email } let(:author_email) { FFaker::Internet.email }
# I have to remove periods from the end of the name # I have to remove periods from the end of the name
...@@ -90,6 +93,26 @@ describe Repository, models: true do ...@@ -90,6 +93,26 @@ describe Repository, models: true do
end end
end end
describe '#ref_name_for_sha' do
context 'ref found' do
it 'returns the ref' do
allow_any_instance_of(Gitlab::Popen).to receive(:popen).
and_return(["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0])
expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq 'refs/environments/production/77'
end
end
context 'ref not found' do
it 'returns nil' do
allow_any_instance_of(Gitlab::Popen).to receive(:popen).
and_return(["", 0])
expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq nil
end
end
end
describe '#last_commit_for_path' do describe '#last_commit_for_path' do
subject { repository.last_commit_for_path(sample_commit.id, '.gitignore').id } subject { repository.last_commit_for_path(sample_commit.id, '.gitignore').id }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment