shipit-engine 0.15.0 → 0.16.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +34 -1
- data/app/assets/javascripts/shipit/page_updater.js.coffee +63 -0
- data/app/assets/javascripts/shipit/stacks.js.coffee +9 -21
- data/app/assets/stylesheets/_base/_base.scss +2 -2
- data/app/assets/stylesheets/_base/_colors.scss +0 -1
- data/app/assets/stylesheets/_base/_forms.scss +14 -0
- data/app/assets/stylesheets/_pages/_commits.scss +16 -6
- data/app/assets/stylesheets/_pages/_settings.scss +8 -0
- data/app/assets/stylesheets/_pages/_stacks.scss +1 -1
- data/app/controllers/shipit/api/base_controller.rb +7 -3
- data/app/controllers/shipit/api/ccmenu_controller.rb +33 -0
- data/app/controllers/shipit/api/pull_requests_controller.rb +36 -0
- data/app/controllers/shipit/api/stacks_controller.rb +1 -0
- data/app/controllers/shipit/ccmenu_url_controller.rb +22 -0
- data/app/controllers/shipit/pull_requests_controller.rb +30 -0
- data/app/controllers/shipit/stacks_controller.rb +7 -2
- data/app/controllers/shipit/webhooks_controller.rb +1 -2
- data/app/helpers/shipit/github_url_helper.rb +8 -2
- data/app/helpers/shipit/shipit_helper.rb +9 -0
- data/app/helpers/shipit/stacks_helper.rb +22 -7
- data/app/jobs/shipit/background_job/unique.rb +19 -1
- data/app/jobs/shipit/cache_deploy_spec_job.rb +1 -1
- data/app/jobs/shipit/merge_pull_requests_job.rb +26 -0
- data/app/jobs/shipit/perform_task_job.rb +1 -1
- data/app/jobs/shipit/refresh_pull_request_job.rb +8 -0
- data/app/models/concerns/shipit/deferred_touch.rb +6 -1
- data/app/models/shipit/anonymous_user.rb +4 -0
- data/app/models/shipit/application_record.rb +5 -0
- data/app/models/shipit/commit.rb +51 -49
- data/app/models/shipit/commit_message.rb +32 -0
- data/app/models/shipit/deploy.rb +5 -0
- data/app/models/shipit/deploy_spec.rb +26 -1
- data/app/models/shipit/deploy_spec/file_system.rb +6 -1
- data/app/models/shipit/deploy_spec/kubernetes_discovery.rb +10 -13
- data/app/models/shipit/deploy_spec/npm_discovery.rb +2 -1
- data/app/models/shipit/duration.rb +3 -1
- data/app/models/shipit/hook.rb +1 -0
- data/app/models/shipit/pull_request.rb +252 -0
- data/app/models/shipit/stack.rb +33 -17
- data/app/models/shipit/status.rb +1 -16
- data/app/models/shipit/status/common.rb +45 -0
- data/app/models/shipit/status/group.rb +82 -0
- data/app/models/shipit/status/missing.rb +30 -0
- data/app/models/shipit/status/unknown.rb +33 -0
- data/app/models/shipit/unlimited_api_client.rb +10 -0
- data/app/serializers/shipit/commit_serializer.rb +1 -1
- data/app/serializers/shipit/pull_request_serializer.rb +20 -0
- data/app/serializers/shipit/stack_serializer.rb +6 -2
- data/app/views/layouts/shipit.html.erb +41 -39
- data/app/views/shipit/ccmenu/project.xml.builder +13 -0
- data/app/views/shipit/commits/_commit.html.erb +1 -1
- data/app/views/shipit/deploys/_deploy.html.erb +1 -1
- data/app/views/shipit/pull_requests/_pull_request.html.erb +29 -0
- data/app/views/shipit/pull_requests/index.html.erb +20 -0
- data/app/views/shipit/shared/_author.html.erb +7 -0
- data/app/views/shipit/stacks/_header.html.erb +5 -0
- data/app/views/shipit/stacks/settings.html.erb +13 -0
- data/app/views/shipit/stacks/show.html.erb +3 -2
- data/app/views/shipit/statuses/_group.html.erb +1 -1
- data/app/views/shipit/tasks/_task.html.erb +1 -1
- data/config/initializers/inflections.rb +3 -0
- data/config/locales/en.yml +1 -3
- data/config/routes.rb +8 -0
- data/db/migrate/20170130113633_create_shipit_pull_requests.rb +25 -0
- data/db/migrate/20170208143657_add_pull_request_number_and_title_to_commits.rb +7 -0
- data/db/migrate/20170208154609_backfill_merge_commits.rb +13 -0
- data/db/migrate/20170209160355_add_branch_to_pull_requests.rb +5 -0
- data/db/migrate/20170215123538_add_merge_queue_enabled_to_stacks.rb +5 -0
- data/db/migrate/20170220152410_improve_users_indexing.rb +6 -0
- data/db/migrate/20170221102128_improve_tasks_indexing.rb +8 -0
- data/db/migrate/20170221130336_add_last_revalidated_at_on_pull_requests.rb +10 -0
- data/lib/shipit.rb +2 -0
- data/lib/shipit/version.rb +1 -1
- data/lib/tasks/cron.rake +1 -0
- data/test/controllers/api/ccmenu_controller_test.rb +57 -0
- data/test/controllers/api/commits_controller_test.rb +1 -1
- data/test/controllers/api/pull_requests_controller_test.rb +59 -0
- data/test/controllers/ccmenu_controller_test.rb +33 -0
- data/test/controllers/pull_requests_controller_test.rb +31 -0
- data/test/controllers/webhooks_controller_test.rb +3 -4
- data/test/dummy/config/environments/development.rb +3 -1
- data/test/dummy/data/stacks/shopify/junk/production/git/README.md +8 -0
- data/test/dummy/data/stacks/shopify/junk/production/git/circle.yml +4 -0
- data/test/dummy/data/stacks/shopify/junk/production/git/shipit.yml +4 -0
- data/test/dummy/db/development.sqlite3 +0 -0
- data/test/dummy/db/schema.rb +45 -11
- data/test/dummy/db/seeds.rb +33 -10
- data/test/dummy/db/test.sqlite3 +0 -0
- data/test/fixtures/shipit/commits.yml +14 -0
- data/test/fixtures/shipit/pull_requests.yml +56 -0
- data/test/fixtures/shipit/stacks.yml +5 -1
- data/test/fixtures/shipit/statuses.yml +8 -0
- data/test/helpers/json_helper.rb +16 -14
- data/test/jobs/merge_pull_requests_job_test.rb +59 -0
- data/test/models/commits_test.rb +104 -49
- data/test/{unit → models}/deploy_spec_test.rb +138 -12
- data/test/models/deploys_test.rb +10 -4
- data/test/models/pull_request_test.rb +197 -0
- data/test/models/stacks_test.rb +46 -53
- data/test/models/status/group_test.rb +44 -0
- data/test/models/status/missing_test.rb +23 -0
- data/test/models/status_test.rb +3 -6
- data/test/unit/csv_serializer_test.rb +10 -2
- metadata +57 -12
- data/app/models/shipit/missing_status.rb +0 -21
- data/app/models/shipit/status_group.rb +0 -35
- data/app/models/shipit/unknown_status.rb +0 -48
- data/app/views/shipit/commits/_commit_author.html.erb +0 -7
- data/test/models/missing_status_test.rb +0 -23
- data/test/models/status_group_test.rb +0 -26
data/app/models/shipit/status.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
module Shipit
|
2
2
|
class Status < ActiveRecord::Base
|
3
|
+
include Common
|
3
4
|
include DeferredTouch
|
4
5
|
|
5
6
|
STATES = %w(pending success failure error).freeze
|
@@ -30,22 +31,6 @@ module Shipit
|
|
30
31
|
end
|
31
32
|
end
|
32
33
|
|
33
|
-
def unknown?
|
34
|
-
false
|
35
|
-
end
|
36
|
-
|
37
|
-
def ignored?
|
38
|
-
stack.soft_failing_statuses.include?(context)
|
39
|
-
end
|
40
|
-
|
41
|
-
def group?
|
42
|
-
false
|
43
|
-
end
|
44
|
-
|
45
|
-
def simple_state
|
46
|
-
state == 'error' ? 'failure' : state
|
47
|
-
end
|
48
|
-
|
49
34
|
private
|
50
35
|
|
51
36
|
def enable_ci_on_stack
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Shipit
|
2
|
+
class Status
|
3
|
+
module Common
|
4
|
+
def unknown?
|
5
|
+
state == 'unknown'.freeze
|
6
|
+
end
|
7
|
+
|
8
|
+
def pending?
|
9
|
+
state == 'pending'.freeze
|
10
|
+
end
|
11
|
+
|
12
|
+
def success?
|
13
|
+
state == 'success'.freeze
|
14
|
+
end
|
15
|
+
|
16
|
+
def error?
|
17
|
+
state == 'error'.freeze
|
18
|
+
end
|
19
|
+
|
20
|
+
def failure?
|
21
|
+
state == 'failure'.freeze
|
22
|
+
end
|
23
|
+
|
24
|
+
def group?
|
25
|
+
false
|
26
|
+
end
|
27
|
+
|
28
|
+
def simple_state
|
29
|
+
state == 'error'.freeze ? 'failure'.freeze : state
|
30
|
+
end
|
31
|
+
|
32
|
+
def allowed_to_fail?
|
33
|
+
commit.soft_failing_statuses.include?(context)
|
34
|
+
end
|
35
|
+
|
36
|
+
def hidden?
|
37
|
+
commit.hidden_statuses.include?(context)
|
38
|
+
end
|
39
|
+
|
40
|
+
def required?
|
41
|
+
commit.required_statuses.include?(context)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Shipit
|
2
|
+
class Status
|
3
|
+
class Group
|
4
|
+
include Common
|
5
|
+
|
6
|
+
attr_reader :commit, :statuses
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def compact(commit, statuses)
|
10
|
+
group = new(commit, statuses)
|
11
|
+
case group.size
|
12
|
+
when 0
|
13
|
+
Status::Unknown.new(commit)
|
14
|
+
when 1
|
15
|
+
group.statuses.first
|
16
|
+
else
|
17
|
+
group
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(commit, statuses)
|
23
|
+
@commit = commit
|
24
|
+
|
25
|
+
visible_statuses = reject_hidden(statuses.to_a.uniq(&:context))
|
26
|
+
missing_contexts = required_statuses - visible_statuses.map(&:context)
|
27
|
+
visible_statuses += missing_contexts.map { |c| Status::Missing.new(commit, c) }
|
28
|
+
|
29
|
+
@statuses = visible_statuses.sort_by!(&:context)
|
30
|
+
end
|
31
|
+
|
32
|
+
delegate :pending?, :success?, :error?, :failure?, :unknown?, :state, :simple_state, to: :significant_status
|
33
|
+
delegate :each, :size, :map, to: :statuses
|
34
|
+
delegate :required_statuses, to: :commit
|
35
|
+
|
36
|
+
def to_a
|
37
|
+
@statuses.dup
|
38
|
+
end
|
39
|
+
|
40
|
+
def description
|
41
|
+
"#{success_count} / #{statuses.count} checks OK"
|
42
|
+
end
|
43
|
+
|
44
|
+
def target_url
|
45
|
+
end
|
46
|
+
|
47
|
+
def to_partial_path
|
48
|
+
'statuses/group'
|
49
|
+
end
|
50
|
+
|
51
|
+
def group?
|
52
|
+
true
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def reject_hidden(statuses)
|
58
|
+
statuses.reject(&:hidden?)
|
59
|
+
end
|
60
|
+
|
61
|
+
def reject_allowed_to_fail(statuses)
|
62
|
+
statuses.reject(&:allowed_to_fail?)
|
63
|
+
end
|
64
|
+
|
65
|
+
def significant_status
|
66
|
+
@significant_status ||= select_significant_status(statuses)
|
67
|
+
end
|
68
|
+
|
69
|
+
def select_significant_status(statuses)
|
70
|
+
statuses = reject_allowed_to_fail(statuses)
|
71
|
+
return Status::Unknown.new(commit) if statuses.empty?
|
72
|
+
non_success_statuses = statuses.reject(&:success?)
|
73
|
+
return statuses.first if non_success_statuses.empty?
|
74
|
+
non_success_statuses.reject(&:pending?).first || non_success_statuses.first || Status::Unknown.new(commit)
|
75
|
+
end
|
76
|
+
|
77
|
+
def success_count
|
78
|
+
@statuses.count(&:success?)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Shipit
|
2
|
+
class Status
|
3
|
+
class Missing
|
4
|
+
include Common
|
5
|
+
|
6
|
+
attr_reader :commit, :context
|
7
|
+
|
8
|
+
def initialize(commit, context)
|
9
|
+
@commit = commit
|
10
|
+
@context = context
|
11
|
+
end
|
12
|
+
|
13
|
+
def target_url
|
14
|
+
nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def state
|
18
|
+
'pending'.freeze
|
19
|
+
end
|
20
|
+
|
21
|
+
def description
|
22
|
+
I18n.t('missing_status.description', context: context)
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_partial_path
|
26
|
+
'shipit/statuses/status'
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Shipit
|
2
|
+
class Status
|
3
|
+
class Unknown
|
4
|
+
include Common
|
5
|
+
|
6
|
+
attr_reader :commit
|
7
|
+
|
8
|
+
def initialize(commit)
|
9
|
+
@commit = commit
|
10
|
+
end
|
11
|
+
|
12
|
+
def state
|
13
|
+
'unknown'.freeze
|
14
|
+
end
|
15
|
+
|
16
|
+
def target_url
|
17
|
+
nil
|
18
|
+
end
|
19
|
+
|
20
|
+
def description
|
21
|
+
''
|
22
|
+
end
|
23
|
+
|
24
|
+
def context
|
25
|
+
'ci/unknown'
|
26
|
+
end
|
27
|
+
|
28
|
+
def to_partial_path
|
29
|
+
'shipit/statuses/status'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Shipit
|
2
|
+
class PullRequestSerializer < ActiveModel::Serializer
|
3
|
+
include GithubUrlHelper
|
4
|
+
include ConditionalAttributes
|
5
|
+
|
6
|
+
has_one :merge_requested_by
|
7
|
+
has_one :head, serializer: ShortCommitSerializer
|
8
|
+
|
9
|
+
attributes :id, :number, :title, :github_id, :additions, :deletions, :state, :merge_status, :mergeable,
|
10
|
+
:merge_requested_at, :rejection_reason, :html_url
|
11
|
+
|
12
|
+
def html_url
|
13
|
+
github_pull_request_url(object)
|
14
|
+
end
|
15
|
+
|
16
|
+
def include_rejection_reason?
|
17
|
+
object.rejection_reason?
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -3,8 +3,8 @@ module Shipit
|
|
3
3
|
include ConditionalAttributes
|
4
4
|
|
5
5
|
has_one :lock_author
|
6
|
-
attributes :id, :repo_owner, :repo_name, :environment, :html_url, :url, :tasks_url, :deploy_url, :
|
7
|
-
:undeployed_commits_count, :is_locked, :lock_reason, :continuous_deployment, :created_at,
|
6
|
+
attributes :id, :repo_owner, :repo_name, :environment, :html_url, :url, :tasks_url, :deploy_url, :pull_requests_url,
|
7
|
+
:deploy_spec, :undeployed_commits_count, :is_locked, :lock_reason, :continuous_deployment, :created_at,
|
8
8
|
:updated_at, :locked_since
|
9
9
|
|
10
10
|
def url
|
@@ -19,6 +19,10 @@ module Shipit
|
|
19
19
|
api_stack_tasks_url(object)
|
20
20
|
end
|
21
21
|
|
22
|
+
def pull_requests_url
|
23
|
+
api_stack_pull_requests_url(object)
|
24
|
+
end
|
25
|
+
|
22
26
|
def is_locked
|
23
27
|
object.locked?
|
24
28
|
end
|
@@ -5,7 +5,7 @@
|
|
5
5
|
<%= favicon_link_tag %>
|
6
6
|
<%= stylesheet_link_tag :shipit, media: 'all' %>
|
7
7
|
<%= javascript_include_tag :shipit %>
|
8
|
-
|
8
|
+
<%= yield :update_subscription %>
|
9
9
|
<%= csrf_meta_tags %>
|
10
10
|
</head>
|
11
11
|
<body>
|
@@ -26,51 +26,53 @@
|
|
26
26
|
</div>
|
27
27
|
</div>
|
28
28
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
<div class="
|
33
|
-
<div class="
|
34
|
-
<
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
29
|
+
<div id="layout-content">
|
30
|
+
<% github_status = Shipit::GithubStatus.status
|
31
|
+
unless github_status.nil? || github_status[:status] == 'good' %>
|
32
|
+
<div class="banner github-status banner--orange hidden">
|
33
|
+
<div class="banner__inner wrapper">
|
34
|
+
<div class="banner__content">
|
35
|
+
<h2 class="banner__title">GitHub is having issues</h2>
|
36
|
+
<% if github_status[:body].present? %>
|
37
|
+
"<i><%= github_status[:body] %></i>"
|
38
|
+
<% end %>
|
39
|
+
<% if github_status[:last_updated].present? %>
|
40
|
+
<%= time_ago_in_words(github_status[:last_updated]) %> ago
|
41
|
+
<% end %>
|
42
|
+
</div>
|
42
43
|
|
43
|
-
|
44
|
+
<a class="banner__dismiss">×</a>
|
45
|
+
</div>
|
44
46
|
</div>
|
45
|
-
|
46
|
-
<% end %>
|
47
|
+
<% end %>
|
47
48
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
</div>
|
58
|
-
<% if content_for? :primary_navigation %>
|
59
|
-
<div class="header__page-actions">
|
60
|
-
<%= yield :primary_navigation %>
|
49
|
+
<header class="header">
|
50
|
+
<%= link_to "Shipit v#{Shipit::VERSION}", "https://github.com/Shopify/shipit-engine/tree/v#{Shipit::VERSION}", class: 'powered-by' %>
|
51
|
+
<div class="wrapper">
|
52
|
+
<div class="header__inner">
|
53
|
+
<a href="/" class="logo">
|
54
|
+
<span class="visually-hidden">Shipit</span>
|
55
|
+
</a>
|
56
|
+
<div class="header__page-title">
|
57
|
+
<%= yield :page_title %>
|
61
58
|
</div>
|
59
|
+
<% if content_for? :primary_navigation %>
|
60
|
+
<div class="header__page-actions">
|
61
|
+
<%= yield :primary_navigation %>
|
62
|
+
</div>
|
63
|
+
<% end %>
|
64
|
+
</div>
|
65
|
+
<% if content_for? :secondary_navigation %>
|
66
|
+
<nav class="nav">
|
67
|
+
<%= yield :secondary_navigation %>
|
68
|
+
</nav>
|
62
69
|
<% end %>
|
63
70
|
</div>
|
64
|
-
|
65
|
-
<nav class="nav">
|
66
|
-
<%= yield :secondary_navigation %>
|
67
|
-
</nav>
|
68
|
-
<% end %>
|
69
|
-
</div>
|
70
|
-
</header>
|
71
|
+
</header>
|
71
72
|
|
72
|
-
|
73
|
-
|
73
|
+
<div class="main <%= content_for(:main_classes) %>">
|
74
|
+
<%= yield %>
|
75
|
+
</div>
|
74
76
|
</div>
|
75
77
|
</body>
|
76
78
|
</html>
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# Derived from http://timnew.me/blog/2013/04/07/multiple-project-summary-reporting-standard-cctray-xml-feed/
|
2
|
+
status_map = {'backlogged' => 'failure', 'locked' => 'failure'}
|
3
|
+
xml.instruct!
|
4
|
+
xml.Projects do
|
5
|
+
xml.Project '', {
|
6
|
+
:name => stack.to_param,
|
7
|
+
:lastBuildStatus => status_map.fetch(stack.merge_status, stack.merge_status).capitalize,
|
8
|
+
:activity => deploy.running? ? 'Building' : 'Sleeping',
|
9
|
+
:lastBuildTime => deploy.ended_at || deploy.started_at || deploy.created_at,
|
10
|
+
:lastBuildLabel => deploy.id,
|
11
|
+
:webUrl => stack_url(stack),
|
12
|
+
}
|
13
|
+
end
|
@@ -1,5 +1,5 @@
|
|
1
1
|
<li class="commit" id="commit-<%= commit.id %>">
|
2
|
-
<%= render 'shipit/
|
2
|
+
<%= render 'shipit/shared/author', author: commit.author %>
|
3
3
|
<%= render commit.status %>
|
4
4
|
<div class="commit-details">
|
5
5
|
<span class="commit-title"><%= render_commit_message_with_link commit %></span>
|
@@ -1,7 +1,7 @@
|
|
1
1
|
<%- read_only ||= false -%>
|
2
2
|
|
3
3
|
<li class="task deploy" id="task-<%= deploy.id %>" data-status="<%= deploy.status %>">
|
4
|
-
<%= render 'shipit/
|
4
|
+
<%= render 'shipit/shared/author', author: deploy.author %>
|
5
5
|
<a href="<%= stack_deploy_path(@stack, deploy) %>" class="status status--<%= deploy.status %>" data-tooltip="<%= deploy.status.capitalize %>">
|
6
6
|
<i class="status__icon"></i>
|
7
7
|
<span class="visually-hidden"><%= deploy.status %></span>
|
@@ -0,0 +1,29 @@
|
|
1
|
+
<li class="pr" id="pr-<%= pull_request.id %>">
|
2
|
+
<%= render 'shipit/shared/author', author: pull_request.merge_requested_by %>
|
3
|
+
<div class="pr-details">
|
4
|
+
<span class="pr-number">
|
5
|
+
<%= pull_request_link(pull_request) %>
|
6
|
+
</span>
|
7
|
+
<span class="pr-title">
|
8
|
+
<%= render_pull_request_title_with_link pull_request %>
|
9
|
+
</span>
|
10
|
+
<p class="pr-meta">
|
11
|
+
<span class="code-additions">+<%= pull_request.additions %></span>
|
12
|
+
<span class="code-deletions">-<%= pull_request.deletions %></span>
|
13
|
+
</p>
|
14
|
+
<p class="pr-meta">
|
15
|
+
Enqueued <%= timeago_tag(pull_request.merge_requested_at, force: true) %>
|
16
|
+
<% if pull_request.revalidating? %>
|
17
|
+
<em class="warning">Need revalidation.</em>
|
18
|
+
<% end %>
|
19
|
+
</p>
|
20
|
+
</div>
|
21
|
+
<% if pull_request.revalidating? %>
|
22
|
+
<div class="commit-actions">
|
23
|
+
<%= button_to 'Confirm', stack_pull_requests_path(pull_request.stack, number_or_url: pull_request.number), class: 'btn btn--warning', method: 'post' %>
|
24
|
+
</div>
|
25
|
+
<% end %>
|
26
|
+
<div class="commit-actions">
|
27
|
+
<%= button_to 'Cancel', stack_pull_request_path(pull_request.stack, pull_request), class: 'btn btn--warning', method: 'delete' %>
|
28
|
+
</div>
|
29
|
+
</li>
|