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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. 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
- PMS::ProjectItem.create!(params[:title], draft: true, body: params[:body], project_number: project_number)
11
- flash[:success] = 'Draft item created.'
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 = PMS::Issue.find(params[:issue_number].to_i)
14
- PMS::ProjectItem.create!(issue, project_number: project_number)
15
- flash[:success] = "Issue ##{issue.number} added to project."
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, PMS::Error, Octokit::Error => e
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
- # PATCH /projects/:project_id/items/:id/move
25
- def move
26
- item = find_project_item
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
- flash[:success] = "Item assigned to #{assignees.join(', ')}."
45
- redirect_to(plan_my_stuff.project_path(params[:project_id]))
46
- rescue ArgumentError, PMS::Error => e
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
- # PATCH /projects/:project_id/items/:id/unassign
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.assign!(remaining)
45
+ yield(item) if block_given?
46
+ return if performed?
58
47
 
59
- flash[:success] = "#{params[:username]} unassigned."
60
- redirect_to(plan_my_stuff.project_path(params[:project_id]))
61
- rescue ArgumentError, PMS::Error => e
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(params[:project_id]))
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
- @projects = PMS::Project.list.reject(&:closed)
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 = PMS::Project.find(params[:id].to_i)
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