plan_my_stuff 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +595 -0
- data/CONFIGURATION.md +487 -0
- data/README.md +612 -88
- data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
- data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
- data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
- data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
- data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
- data/app/jobs/plan_my_stuff/application_job.rb +8 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
- data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
- data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
- data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
- data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
- data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +43 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
- data/lib/plan_my_stuff/application_record.rb +158 -1
- data/lib/plan_my_stuff/approval.rb +88 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +12 -0
- data/lib/plan_my_stuff/attachment.rb +83 -0
- data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
- data/lib/plan_my_stuff/base_metadata.rb +25 -28
- data/lib/plan_my_stuff/base_project.rb +502 -0
- data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
- data/lib/plan_my_stuff/base_project_item.rb +588 -0
- data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
- data/lib/plan_my_stuff/cache.rb +197 -0
- data/lib/plan_my_stuff/client.rb +139 -64
- data/lib/plan_my_stuff/comment.rb +225 -100
- data/lib/plan_my_stuff/comment_metadata.rb +68 -5
- data/lib/plan_my_stuff/configuration.rb +459 -28
- data/lib/plan_my_stuff/custom_fields.rb +96 -12
- data/lib/plan_my_stuff/engine.rb +14 -2
- data/lib/plan_my_stuff/errors.rb +65 -5
- data/lib/plan_my_stuff/graphql/queries.rb +454 -0
- data/lib/plan_my_stuff/issue.rb +1097 -166
- data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
- data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
- data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
- data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
- data/lib/plan_my_stuff/issue_field.rb +126 -0
- data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
- data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
- data/lib/plan_my_stuff/issue_metadata.rb +132 -21
- data/lib/plan_my_stuff/label.rb +100 -13
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/markdown.rb +13 -7
- data/lib/plan_my_stuff/metadata_parser.rb +51 -12
- data/lib/plan_my_stuff/notifications.rb +148 -0
- data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +40 -0
- data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
- data/lib/plan_my_stuff/pipeline.rb +310 -0
- data/lib/plan_my_stuff/project.rb +63 -465
- data/lib/plan_my_stuff/project_item.rb +3 -409
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +47 -0
- data/lib/plan_my_stuff/reminders/closer.rb +70 -0
- data/lib/plan_my_stuff/reminders/fire.rb +129 -0
- data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
- data/lib/plan_my_stuff/reminders.rb +12 -0
- data/lib/plan_my_stuff/repo.rb +145 -0
- data/lib/plan_my_stuff/test_helpers.rb +265 -25
- data/lib/plan_my_stuff/testing_project.rb +292 -0
- data/lib/plan_my_stuff/testing_project_item.rb +218 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +24 -3
- data/lib/plan_my_stuff/verifier.rb +10 -0
- data/lib/plan_my_stuff/version.rb +2 -2
- data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
- data/lib/plan_my_stuff.rb +55 -20
- data/lib/tasks/plan_my_stuff.rake +331 -0
- metadata +99 -4
|
@@ -1,93 +1,56 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
class ProjectItemsController < ApplicationController
|
|
4
|
+
class ProjectItemsController < PlanMyStuff::ApplicationController
|
|
5
5
|
# POST /projects/:project_id/items
|
|
6
6
|
def create
|
|
7
7
|
project_number = params[:project_id].to_i
|
|
8
8
|
|
|
9
9
|
if params[:draft] == '1'
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
item = PlanMyStuff::ProjectItem.create!(
|
|
11
|
+
params[:title],
|
|
12
|
+
draft: true,
|
|
13
|
+
body: params[:body],
|
|
14
|
+
project_number: project_number,
|
|
15
|
+
)
|
|
16
|
+
flash_message = 'Draft item created.'
|
|
12
17
|
else
|
|
13
|
-
issue =
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
issue = PlanMyStuff::Issue.find(params[:issue_number].to_i)
|
|
19
|
+
item = PlanMyStuff::ProjectItem.create!(issue, project_number: project_number)
|
|
20
|
+
flash_message = "Issue ##{issue.number} added to project."
|
|
16
21
|
end
|
|
17
22
|
|
|
23
|
+
yield(item) if block_given?
|
|
24
|
+
return if performed?
|
|
25
|
+
|
|
26
|
+
flash[:success] = flash_message
|
|
18
27
|
redirect_to(plan_my_stuff.project_path(project_number))
|
|
19
|
-
rescue ArgumentError,
|
|
28
|
+
rescue ArgumentError, PlanMyStuff::Error, Octokit::Error => e
|
|
29
|
+
pms_handle_rescue(e)
|
|
20
30
|
flash[:error] = e.message
|
|
21
31
|
redirect_to(plan_my_stuff.project_path(project_number))
|
|
22
32
|
end
|
|
23
33
|
|
|
24
|
-
#
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
item.move_to!(params[:status])
|
|
29
|
-
|
|
30
|
-
flash[:success] = "Item moved to #{params[:status]}."
|
|
31
|
-
redirect_to(plan_my_stuff.project_path(params[:project_id]))
|
|
32
|
-
rescue ArgumentError, PMS::Error => e
|
|
33
|
-
flash[:error] = e.message
|
|
34
|
-
redirect_to(plan_my_stuff.project_path(params[:project_id]))
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# PATCH /projects/:project_id/items/:id/assign
|
|
38
|
-
def assign
|
|
39
|
-
item = find_project_item
|
|
40
|
-
assignees = parse_assignees(params[:assignee])
|
|
41
|
-
|
|
42
|
-
item.assign!(assignees)
|
|
34
|
+
# DELETE /projects/:project_id/items/:id
|
|
35
|
+
def destroy
|
|
36
|
+
project_number = params[:project_id].to_i
|
|
37
|
+
item_id = params[:id]
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
flash[:error] = e.message
|
|
48
|
-
redirect_to(plan_my_stuff.project_path(params[:project_id]))
|
|
49
|
-
end
|
|
39
|
+
project = PlanMyStuff::Project.find(project_number)
|
|
40
|
+
item = project.items.find { |i| i.id == item_id }
|
|
41
|
+
raise(PlanMyStuff::APIError, "Item not found: #{item_id}") if item.nil?
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
def unassign
|
|
53
|
-
item = find_project_item
|
|
54
|
-
current_assignees = item.field_values['Assignees'] || []
|
|
55
|
-
remaining = current_assignees - [params[:username]]
|
|
43
|
+
item.destroy!(user: pms_current_user)
|
|
56
44
|
|
|
57
|
-
item
|
|
45
|
+
yield(item) if block_given?
|
|
46
|
+
return if performed?
|
|
58
47
|
|
|
59
|
-
flash[:success] =
|
|
60
|
-
redirect_to(plan_my_stuff.project_path(
|
|
61
|
-
rescue ArgumentError,
|
|
48
|
+
flash[:success] = 'Item removed from project.'
|
|
49
|
+
redirect_to(plan_my_stuff.project_path(project_number))
|
|
50
|
+
rescue ArgumentError, PlanMyStuff::Error, Octokit::Error => e
|
|
51
|
+
pms_handle_rescue(e)
|
|
62
52
|
flash[:error] = e.message
|
|
63
|
-
redirect_to(plan_my_stuff.project_path(
|
|
53
|
+
redirect_to(plan_my_stuff.project_path(project_number))
|
|
64
54
|
end
|
|
65
|
-
|
|
66
|
-
private
|
|
67
|
-
|
|
68
|
-
# Finds the project and the item within it.
|
|
69
|
-
#
|
|
70
|
-
# @return [PlanMyStuff::ProjectItem]
|
|
71
|
-
#
|
|
72
|
-
def find_project_item
|
|
73
|
-
project = PMS::Project.find(params[:project_id].to_i)
|
|
74
|
-
item = project.items.find { |i| i.id == params[:id] }
|
|
75
|
-
|
|
76
|
-
raise(PMS::APIError, "Item not found: #{params[:id]}") unless item
|
|
77
|
-
|
|
78
|
-
item
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Splits a comma-separated assignees string into an array.
|
|
82
|
-
#
|
|
83
|
-
# @param assignees_string [String, nil]
|
|
84
|
-
#
|
|
85
|
-
# @return [Array<String>]
|
|
86
|
-
#
|
|
87
|
-
def parse_assignees(assignees_string)
|
|
88
|
-
return [] if assignees_string.blank?
|
|
89
|
-
|
|
90
|
-
assignees_string.split(',').filter_map { |a| a.strip.presence }
|
|
91
|
-
end
|
|
92
55
|
end
|
|
93
56
|
end
|
|
@@ -1,17 +1,95 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
class ProjectsController < ApplicationController
|
|
4
|
+
class ProjectsController < PlanMyStuff::ApplicationController
|
|
5
5
|
# GET /projects
|
|
6
6
|
def index
|
|
7
|
-
|
|
7
|
+
all_projects = PlanMyStuff::BaseProject.list.reject(&:closed)
|
|
8
|
+
@filter = params[:filter].presence_in(%w[testing all]) || 'regular'
|
|
9
|
+
@projects =
|
|
10
|
+
case @filter
|
|
11
|
+
when 'testing' then all_projects.select { |p| p.is_a?(PlanMyStuff::TestingProject) }
|
|
12
|
+
when 'regular' then all_projects.reject { |p| p.is_a?(PlanMyStuff::TestingProject) }
|
|
13
|
+
else all_projects
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
yield(@projects) if block_given?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# GET /projects/new
|
|
20
|
+
def new
|
|
21
|
+
@project = PlanMyStuff::Project.new
|
|
22
|
+
|
|
23
|
+
yield(@project) if block_given?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# POST /projects
|
|
27
|
+
def create
|
|
28
|
+
@project = PlanMyStuff::Project.create!(
|
|
29
|
+
title: project_params[:title],
|
|
30
|
+
readme: project_params[:readme] || '',
|
|
31
|
+
description: project_params[:description],
|
|
32
|
+
user: pms_current_user,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
yield(@project) if block_given?
|
|
36
|
+
return if performed?
|
|
37
|
+
|
|
38
|
+
flash[:success] = 'Project was successfully created.'
|
|
39
|
+
redirect_to(plan_my_stuff.project_path(@project.number))
|
|
40
|
+
rescue PlanMyStuff::ValidationError => e
|
|
41
|
+
pms_handle_rescue(e)
|
|
42
|
+
@project = PlanMyStuff::Project.new(
|
|
43
|
+
title: project_params[:title],
|
|
44
|
+
readme: project_params[:readme],
|
|
45
|
+
description: project_params[:description],
|
|
46
|
+
)
|
|
47
|
+
flash.now[:error] = e.message
|
|
48
|
+
render(:new, status: PlanMyStuff.unprocessable_status)
|
|
8
49
|
end
|
|
9
50
|
|
|
10
51
|
# GET /projects/:id
|
|
11
52
|
def show
|
|
12
|
-
@project =
|
|
53
|
+
@project = PlanMyStuff::Project.find(params[:id].to_i)
|
|
13
54
|
@statuses = @project.statuses.pluck(:name)
|
|
14
55
|
@items_by_status = @project.items.group_by(&:status)
|
|
56
|
+
|
|
57
|
+
yield(@project) if block_given?
|
|
15
58
|
end
|
|
59
|
+
|
|
60
|
+
# GET /projects/:id/edit
|
|
61
|
+
def edit
|
|
62
|
+
@project = PlanMyStuff::Project.find(params[:id].to_i)
|
|
63
|
+
|
|
64
|
+
yield(@project) if block_given?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# PATCH/PUT /projects/:id
|
|
68
|
+
def update
|
|
69
|
+
@project = PlanMyStuff::Project.find(params[:id].to_i)
|
|
70
|
+
|
|
71
|
+
@project.update!(
|
|
72
|
+
title: project_params[:title],
|
|
73
|
+
readme: project_params[:readme],
|
|
74
|
+
description: project_params[:description],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
yield(@project) if block_given?
|
|
78
|
+
return if performed?
|
|
79
|
+
|
|
80
|
+
flash[:success] = 'Project was successfully updated.'
|
|
81
|
+
redirect_to(plan_my_stuff.project_path(@project.number))
|
|
82
|
+
rescue PlanMyStuff::StaleObjectError => e
|
|
83
|
+
pms_handle_rescue(e)
|
|
84
|
+
flash.now[:error] = 'Project was modified by someone else. Please review the latest changes and try again.'
|
|
85
|
+
render(:edit, status: PlanMyStuff.unprocessable_status)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# @return [ActionController::Parameters]
|
|
91
|
+
def project_params
|
|
92
|
+
params.require(:project).permit(:title, :readme, :description)
|
|
93
|
+
end
|
|
16
94
|
end
|
|
17
95
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module TestingProjectItems
|
|
5
|
+
# Handles Pass/Fail outcomes for testing project items.
|
|
6
|
+
#
|
|
7
|
+
# Pass is submitted directly via button_to with result: "pass".
|
|
8
|
+
# Fail renders a form (new) to capture result_notes before posting to create.
|
|
9
|
+
#
|
|
10
|
+
class ResultsController < PlanMyStuff::ApplicationController
|
|
11
|
+
# GET /testing_projects/:testing_project_id/items/:item_id/result/new
|
|
12
|
+
def new
|
|
13
|
+
@project_number = params[:testing_project_id].to_i
|
|
14
|
+
@item_id = params[:item_id]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# POST /testing_projects/:testing_project_id/items/:item_id/result
|
|
18
|
+
def create
|
|
19
|
+
project_number = params[:testing_project_id].to_i
|
|
20
|
+
item = find_project_item(project_number)
|
|
21
|
+
|
|
22
|
+
case params[:result]
|
|
23
|
+
when 'pass'
|
|
24
|
+
item.mark_passed!(pms_current_user)
|
|
25
|
+
flash[:success] = 'Item marked as Passed.'
|
|
26
|
+
when 'fail'
|
|
27
|
+
item.mark_failed!(pms_current_user, result_notes: params[:result_notes])
|
|
28
|
+
flash[:success] = 'Item marked as Failed.'
|
|
29
|
+
else
|
|
30
|
+
raise(ArgumentError, "Invalid result: #{params[:result].inspect}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
yield(item) if block_given?
|
|
34
|
+
return if performed?
|
|
35
|
+
|
|
36
|
+
redirect_to(plan_my_stuff.testing_project_path(project_number))
|
|
37
|
+
rescue PlanMyStuff::ValidationError => e
|
|
38
|
+
pms_handle_rescue(e)
|
|
39
|
+
if params[:result] == 'fail'
|
|
40
|
+
flash.now[:error] = e.message
|
|
41
|
+
@project_number = params[:testing_project_id].to_i
|
|
42
|
+
@item_id = params[:item_id]
|
|
43
|
+
render(:new, status: PlanMyStuff.unprocessable_status)
|
|
44
|
+
else
|
|
45
|
+
flash[:error] = e.message
|
|
46
|
+
redirect_to(plan_my_stuff.testing_project_path(params[:testing_project_id].to_i))
|
|
47
|
+
end
|
|
48
|
+
rescue ArgumentError, PlanMyStuff::Error, Octokit::Error => e
|
|
49
|
+
pms_handle_rescue(e)
|
|
50
|
+
flash[:error] = e.message
|
|
51
|
+
redirect_to(plan_my_stuff.testing_project_path(params[:testing_project_id].to_i))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# @param project_number [Integer]
|
|
57
|
+
# @return [PlanMyStuff::TestingProjectItem]
|
|
58
|
+
def find_project_item(project_number)
|
|
59
|
+
project = PlanMyStuff::TestingProject.find(project_number)
|
|
60
|
+
item = project.items.find { |i| i.id == params[:item_id] }
|
|
61
|
+
raise(PlanMyStuff::APIError, "Item not found: #{params[:item_id]}") unless item
|
|
62
|
+
|
|
63
|
+
item
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
class TestingProjectItemsController < PlanMyStuff::ApplicationController
|
|
5
|
+
# GET /testing_projects/:testing_project_id/items/new
|
|
6
|
+
def new
|
|
7
|
+
@project = PlanMyStuff::TestingProject.find(params[:testing_project_id].to_i)
|
|
8
|
+
|
|
9
|
+
yield(@project) if block_given?
|
|
10
|
+
rescue PlanMyStuff::Error, Octokit::Error => e
|
|
11
|
+
pms_handle_rescue(e)
|
|
12
|
+
flash[:error] = e.message
|
|
13
|
+
redirect_to(plan_my_stuff.testing_projects_path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# POST /testing_projects/:testing_project_id/items
|
|
17
|
+
def create
|
|
18
|
+
project_number = params[:testing_project_id].to_i
|
|
19
|
+
item = PlanMyStuff::TestingProjectItem.create!(
|
|
20
|
+
item_params[:title],
|
|
21
|
+
draft: true,
|
|
22
|
+
body: item_params[:body],
|
|
23
|
+
project_number: project_number,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
item.update_testers!(item_params[:testers]) if item_params[:testers].present?
|
|
27
|
+
item.update_watchers!(item_params[:watchers]) if item_params[:watchers].present?
|
|
28
|
+
item.update_due_date!(Date.parse(item_params[:due_date])) if item_params[:due_date].present?
|
|
29
|
+
item.update_pass_mode!(item_params[:pass_mode]) if item_params[:pass_mode].present?
|
|
30
|
+
|
|
31
|
+
yield(item) if block_given?
|
|
32
|
+
return if performed?
|
|
33
|
+
|
|
34
|
+
flash[:success] = 'Item added.'
|
|
35
|
+
redirect_to(plan_my_stuff.testing_project_path(project_number))
|
|
36
|
+
rescue ArgumentError, PlanMyStuff::Error, Octokit::Error => e
|
|
37
|
+
pms_handle_rescue(e)
|
|
38
|
+
flash[:error] = e.message
|
|
39
|
+
redirect_to(plan_my_stuff.testing_project_path(project_number))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# @return [ActionController::Parameters]
|
|
45
|
+
def item_params
|
|
46
|
+
params.permit(:title, :body, :testers, :watchers, :due_date, :pass_mode)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
class TestingProjectsController < PlanMyStuff::ApplicationController
|
|
5
|
+
# GET /testing_projects/new
|
|
6
|
+
def new
|
|
7
|
+
@project = PlanMyStuff::TestingProject.new
|
|
8
|
+
@project.metadata.subject_urls = [params[:subject_url]] if params[:subject_url].present?
|
|
9
|
+
|
|
10
|
+
yield(@project) if block_given?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# POST /testing_projects
|
|
14
|
+
def create
|
|
15
|
+
@project = PlanMyStuff::TestingProject.create!(
|
|
16
|
+
title: testing_project_params[:title],
|
|
17
|
+
description: testing_project_params[:description],
|
|
18
|
+
subject_urls: parse_subject_urls(testing_project_params[:subject_urls]),
|
|
19
|
+
due_date: parse_due_date(testing_project_params[:due_date]),
|
|
20
|
+
deadline_miss_reason: testing_project_params[:deadline_miss_reason],
|
|
21
|
+
user: pms_current_user,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
yield(@project) if block_given?
|
|
25
|
+
return if performed?
|
|
26
|
+
|
|
27
|
+
flash[:success] = 'Testing project was successfully created.'
|
|
28
|
+
redirect_to(plan_my_stuff.testing_project_path(@project.number))
|
|
29
|
+
rescue PlanMyStuff::ValidationError => e
|
|
30
|
+
pms_handle_rescue(e)
|
|
31
|
+
@project = PlanMyStuff::TestingProject.new(
|
|
32
|
+
title: testing_project_params[:title],
|
|
33
|
+
description: testing_project_params[:description],
|
|
34
|
+
)
|
|
35
|
+
@project.metadata.subject_urls = parse_subject_urls(testing_project_params[:subject_urls])
|
|
36
|
+
@project.metadata.due_date = safe_parse_due_date(testing_project_params[:due_date])
|
|
37
|
+
@project.metadata.deadline_miss_reason = testing_project_params[:deadline_miss_reason]
|
|
38
|
+
flash.now[:error] = e.message
|
|
39
|
+
render(:new, status: PlanMyStuff.unprocessable_status)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# GET /testing_projects/:id
|
|
43
|
+
def show
|
|
44
|
+
@project = PlanMyStuff::TestingProject.find(params[:id].to_i)
|
|
45
|
+
@statuses = @project.statuses.pluck(:name)
|
|
46
|
+
@items_by_status = @project.items.group_by(&:status)
|
|
47
|
+
|
|
48
|
+
yield(@project) if block_given?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# GET /testing_projects/:id/edit
|
|
52
|
+
def edit
|
|
53
|
+
@project = PlanMyStuff::TestingProject.find(params[:id].to_i)
|
|
54
|
+
|
|
55
|
+
yield(@project) if block_given?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# PATCH/PUT /testing_projects/:id
|
|
59
|
+
def update
|
|
60
|
+
@project = PlanMyStuff::TestingProject.find(params[:id].to_i)
|
|
61
|
+
|
|
62
|
+
@project.update!(
|
|
63
|
+
title: testing_project_params[:title],
|
|
64
|
+
description: testing_project_params[:description],
|
|
65
|
+
metadata: {
|
|
66
|
+
subject_urls: parse_subject_urls(testing_project_params[:subject_urls]),
|
|
67
|
+
due_date: parse_due_date(testing_project_params[:due_date]),
|
|
68
|
+
deadline_miss_reason: testing_project_params[:deadline_miss_reason],
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
yield(@project) if block_given?
|
|
73
|
+
return if performed?
|
|
74
|
+
|
|
75
|
+
flash[:success] = 'Testing project was successfully updated.'
|
|
76
|
+
redirect_to(plan_my_stuff.testing_project_path(@project.number))
|
|
77
|
+
rescue PlanMyStuff::StaleObjectError => e
|
|
78
|
+
pms_handle_rescue(e)
|
|
79
|
+
flash.now[:error] =
|
|
80
|
+
'Testing project was modified by someone else. Please review the latest changes and try again.'
|
|
81
|
+
render(:edit, status: PlanMyStuff.unprocessable_status)
|
|
82
|
+
rescue PlanMyStuff::ValidationError => e
|
|
83
|
+
pms_handle_rescue(e)
|
|
84
|
+
flash.now[:error] = e.message
|
|
85
|
+
render(:edit, status: PlanMyStuff.unprocessable_status)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# @param raw [String, nil]
|
|
91
|
+
# @return [Array<String>]
|
|
92
|
+
def parse_subject_urls(raw)
|
|
93
|
+
raw.to_s.split("\n").map(&:strip).compact_blank
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param raw [String, nil]
|
|
97
|
+
# @return [Date, nil]
|
|
98
|
+
def parse_due_date(raw)
|
|
99
|
+
return if raw.blank?
|
|
100
|
+
|
|
101
|
+
Date.parse(raw)
|
|
102
|
+
rescue ArgumentError, TypeError
|
|
103
|
+
raise(PlanMyStuff::ValidationError, 'Due date must be a valid date.')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param raw [String, nil]
|
|
107
|
+
# @return [Date, nil]
|
|
108
|
+
def safe_parse_due_date(raw)
|
|
109
|
+
parse_due_date(raw)
|
|
110
|
+
rescue PlanMyStuff::ValidationError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [ActionController::Parameters]
|
|
115
|
+
def testing_project_params
|
|
116
|
+
params.require(:testing_project).permit(
|
|
117
|
+
:title, :description, :due_date, :deadline_miss_reason, :subject_urls,
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module Webhooks
|
|
5
|
+
class AwsController < ActionController::API
|
|
6
|
+
VALID_SNS_MESSAGE_TYPES = %w[Notification SubscriptionConfirmation UnsubscribeConfirmation].freeze
|
|
7
|
+
|
|
8
|
+
before_action :verify_signature
|
|
9
|
+
|
|
10
|
+
# POST /webhooks/aws
|
|
11
|
+
def create
|
|
12
|
+
config = PlanMyStuff.configuration
|
|
13
|
+
message_type = request.headers['x-amz-sns-message-type'].to_s
|
|
14
|
+
|
|
15
|
+
Rails.logger.info("[PlanMyStuff] SNS #{message_type}: #{sns_params.to_unsafe_h.inspect}")
|
|
16
|
+
|
|
17
|
+
if message_type == 'SubscriptionConfirmation'
|
|
18
|
+
confirm_subscription!
|
|
19
|
+
head(:ok)
|
|
20
|
+
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
unless message_type == 'Notification'
|
|
25
|
+
head(:ok)
|
|
26
|
+
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
unless config.process_aws_webhooks
|
|
31
|
+
head(:ok)
|
|
32
|
+
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless matching_service?(config.aws_service_identifier)
|
|
37
|
+
head(:ok)
|
|
38
|
+
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
case sns_message_params.dig(:detail, :event_name)
|
|
43
|
+
when 'SERVICE_DEPLOYMENT_COMPLETED'
|
|
44
|
+
handle_deployment_completed
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
head(:ok)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Visits the SubscribeURL provided by SNS to confirm the subscription. Without this, SNS leaves the
|
|
53
|
+
# endpoint in +PendingConfirmation+ and never delivers Notifications.
|
|
54
|
+
#
|
|
55
|
+
# @return [void]
|
|
56
|
+
#
|
|
57
|
+
def confirm_subscription!
|
|
58
|
+
subscribe_url = sns_params[:subscribe_url].to_s
|
|
59
|
+
if subscribe_url.blank?
|
|
60
|
+
Rails.logger.warn('[PlanMyStuff] SubscriptionConfirmation missing SubscribeURL')
|
|
61
|
+
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
uri = URI.parse(subscribe_url)
|
|
66
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
67
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
68
|
+
response = http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
69
|
+
|
|
70
|
+
Rails.logger.info("[PlanMyStuff] Confirmed SNS subscription: HTTP #{response.code}")
|
|
71
|
+
rescue => e
|
|
72
|
+
Rails.logger.error("[PlanMyStuff] SNS subscription confirmation failed: #{e.class}: #{e.message}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Validates the SNS message signature.
|
|
76
|
+
# Checks message type header and topic ARN first (cheap), then delegates to the configured verifier class
|
|
77
|
+
# for cryptographic certificate-based verification.
|
|
78
|
+
#
|
|
79
|
+
# @return [void]
|
|
80
|
+
#
|
|
81
|
+
def verify_signature
|
|
82
|
+
message_type = request.headers['x-amz-sns-message-type'].to_s
|
|
83
|
+
|
|
84
|
+
if VALID_SNS_MESSAGE_TYPES.exclude?(message_type)
|
|
85
|
+
head(:unauthorized)
|
|
86
|
+
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
config = PlanMyStuff.configuration
|
|
91
|
+
|
|
92
|
+
unless sns_params[:topic_arn] == config.sns_topic_arn
|
|
93
|
+
head(:unauthorized)
|
|
94
|
+
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
verifier_error = config.sns_verifier_error
|
|
99
|
+
config.sns_verifier_class.new.authenticate!(raw_body)
|
|
100
|
+
rescue verifier_error
|
|
101
|
+
head(:unauthorized)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [String]
|
|
105
|
+
def raw_body
|
|
106
|
+
@raw_body ||=
|
|
107
|
+
begin
|
|
108
|
+
body = request.body.read
|
|
109
|
+
request.body.rewind
|
|
110
|
+
body
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Parses the SNS envelope. Returns empty params if the body is not valid JSON so signature verification
|
|
115
|
+
# rejects the request with 401 instead of bubbling a 500.
|
|
116
|
+
#
|
|
117
|
+
# @return [ActionController::Parameters]
|
|
118
|
+
#
|
|
119
|
+
def sns_params
|
|
120
|
+
@sns_params ||= ActionController::Parameters.new(JSON.parse(raw_body).deep_transform_keys(&:underscore))
|
|
121
|
+
rescue JSON::ParserError
|
|
122
|
+
@sns_params ||= ActionController::Parameters.new
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Parses the nested SNS message payload. Returns empty params on malformed JSON so callers degrade
|
|
126
|
+
# gracefully.
|
|
127
|
+
#
|
|
128
|
+
# @return [ActionController::Parameters]
|
|
129
|
+
#
|
|
130
|
+
def sns_message_params
|
|
131
|
+
parsed = JSON.parse(sns_params.fetch(:message, '{}')).deep_transform_keys(&:underscore)
|
|
132
|
+
ActionController::Parameters.new(parsed)
|
|
133
|
+
rescue JSON::ParserError
|
|
134
|
+
ActionController::Parameters.new
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Checks whether any resource ARN in the ECS event matches the configured service identifier suffix.
|
|
138
|
+
#
|
|
139
|
+
# @param service_identifier [String, nil]
|
|
140
|
+
#
|
|
141
|
+
# @return [Boolean]
|
|
142
|
+
#
|
|
143
|
+
def matching_service?(service_identifier)
|
|
144
|
+
return false if service_identifier.blank?
|
|
145
|
+
|
|
146
|
+
sns_message_params.fetch(:resources, []).any? { |arn| arn.end_with?(service_identifier) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Finds "Release in Progress" items whose linked issue commit SHA matches the configured production
|
|
150
|
+
# commit SHA (prefix match), then completes deployment for each. After the per-item sweep, fires one batch
|
|
151
|
+
# +pipeline_deployment_completed.plan_my_stuff+ event carrying every item that actually transitioned
|
|
152
|
+
# (+complete_deployment!+ returns +nil+ when auto-complete is off, so those are excluded). The batch event is
|
|
153
|
+
# skipped when nothing transitioned so subscribers only see real deployments.
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
156
|
+
#
|
|
157
|
+
def handle_deployment_completed
|
|
158
|
+
sha = PlanMyStuff.configuration.production_commit_sha
|
|
159
|
+
|
|
160
|
+
if sha.blank?
|
|
161
|
+
Rails.logger.error(
|
|
162
|
+
'[PlanMyStuff] production_commit_sha is not configured - skipping deployment completion',
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
completed_items = release_in_progress_items.filter_map do |item|
|
|
169
|
+
next if item.draft?
|
|
170
|
+
|
|
171
|
+
next unless item.issue.metadata.commit_sha&.start_with?(sha)
|
|
172
|
+
|
|
173
|
+
result = PlanMyStuff::Pipeline.complete_deployment!(item)
|
|
174
|
+
item if result.present?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
return if completed_items.empty?
|
|
178
|
+
|
|
179
|
+
PlanMyStuff::Pipeline.instrument(
|
|
180
|
+
'deployment_completed',
|
|
181
|
+
completed_items,
|
|
182
|
+
commit_sha: sha,
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Finds all pipeline project items at "Release in Progress" status.
|
|
187
|
+
#
|
|
188
|
+
# @return [Array<PlanMyStuff::ProjectItem>]
|
|
189
|
+
#
|
|
190
|
+
def release_in_progress_items
|
|
191
|
+
project_number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!
|
|
192
|
+
project = PlanMyStuff::Project.find(project_number)
|
|
193
|
+
|
|
194
|
+
release_status = PlanMyStuff::Pipeline.resolve_status_name(
|
|
195
|
+
PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
project.items.select { |item| item.status == release_status }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|