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
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Pipeline
5
+ # Resolves linked issues from pull request payloads and finds their
6
+ # corresponding ProjectItems in the pipeline project.
7
+ #
8
+ # Uses +module_function+ pattern (matches +MetadataParser+, +Pipeline+).
9
+ #
10
+ module IssueLinker
11
+ module_function
12
+
13
+ # Regex matching GitHub closing keywords in PR bodies.
14
+ # Matches all nine variants: close/closes/closed, fix/fixes/fixed,
15
+ # resolve/resolves/resolved (case-insensitive).
16
+ CLOSING_KEYWORD_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/i
17
+
18
+ # Extracts issue numbers from a PR payload by parsing the PR body
19
+ # and commit messages for GitHub closing keywords (close/closes/closed,
20
+ # fix/fixes/fixed, resolve/resolves/resolved).
21
+ #
22
+ # @param pr_payload [Hash] parsed PR webhook payload (expects +:body+ or +"body"+ key)
23
+ # @param commit_messages [Array<String>] commit messages to scan (default: [])
24
+ #
25
+ # @return [Array<Integer>] referenced issue numbers (empty if none found)
26
+ #
27
+ def extract_issue_numbers(pr_payload, commit_messages: [])
28
+ texts = []
29
+
30
+ body = pr_payload[:body] || pr_payload['body']
31
+ texts << body if body.present?
32
+
33
+ Array.wrap(commit_messages).each do |message|
34
+ texts << message if message.present?
35
+ end
36
+
37
+ return [] if texts.empty?
38
+
39
+ matches = texts.join("\n").scan(CLOSING_KEYWORD_PATTERN).flatten
40
+ matches.map!(&:to_i)
41
+ matches.uniq!
42
+ matches
43
+ end
44
+
45
+ # Finds the ProjectItem in the pipeline project that matches the given
46
+ # issue number. Loads the full project and iterates its items.
47
+ #
48
+ # @param issue_number [Integer] issue number to find
49
+ # @param repo [Symbol, String, nil] unused, reserved for future multi-repo support
50
+ # @param project_number [Integer, nil] pipeline project number
51
+ #
52
+ # @return [PlanMyStuff::ProjectItem, nil] matching item, or nil if not found
53
+ #
54
+ def find_project_item(issue_number, _repo: nil, project_number: nil)
55
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!(project_number)
56
+ project = PlanMyStuff::Project.find(number)
57
+
58
+ project.items.find { |item| item.number == issue_number }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Pipeline
5
+ # Canonical pipeline status constants.
6
+ #
7
+ # Each constant holds the human-readable status name that maps to a
8
+ # GitHub Projects V2 single-select field value. Consuming apps can
9
+ # override display names via +PlanMyStuff.configuration.pipeline_statuses+
10
+ # but the canonical names here remain the internal identifiers.
11
+ #
12
+ module Status
13
+ STARTED = 'Started'
14
+ IN_REVIEW = 'In Review'
15
+ READY_FOR_RELEASE = 'Ready for Release'
16
+ RELEASE_IN_PROGRESS = 'Release in Progress'
17
+ COMPLETED = 'Completed'
18
+
19
+ # All statuses in pipeline order (frozen).
20
+ ALL = [
21
+ STARTED,
22
+ IN_REVIEW,
23
+ READY_FOR_RELEASE,
24
+ RELEASE_IN_PROGRESS,
25
+ COMPLETED,
26
+ ].freeze
27
+
28
+ # Converts a canonical status name to a snake_case key suitable for
29
+ # notification event names.
30
+ #
31
+ # @param canonical [String] one of the canonical status names
32
+ #
33
+ # @return [String] snake_case key (e.g. +"ready_for_release"+)
34
+ #
35
+ def self.key_for(canonical)
36
+ canonical.downcase.tr(' ', '_')
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Pipeline
5
+ # Canonical names for the +Testing+ single-select custom field on the
6
+ # pipeline project. The field tracks testing progress orthogonally to
7
+ # the +Status+ field (testing can run alongside +In Review+).
8
+ #
9
+ # Consuming apps can override the field name via
10
+ # +PlanMyStuff.configuration.pipeline_testing_field_name+ and the
11
+ # option labels via +PlanMyStuff.configuration.pipeline_testing_values+.
12
+ # The canonical names here remain the internal identifiers.
13
+ #
14
+ module Testing
15
+ FIELD_NAME = 'Testing'
16
+
17
+ VALUES = {
18
+ active: 'Testing',
19
+ inactive: 'Not testing',
20
+ }.freeze
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # High-level orchestration layer for the release pipeline.
5
+ #
6
+ # Named lifecycle methods wrap the low-level +ProjectItem.move_to!+ calls, resolve configurable status aliases, and
7
+ # fire +ActiveSupport::Notifications+ events on every transition.
8
+ #
9
+ # No transition enforcement -- any status can move to any other.
10
+ #
11
+ module Pipeline
12
+ module_function
13
+
14
+ # Raises +PlanMyStuff::PendingApprovalsError+ when +issue+ has pending manager approvals. No-op for +nil+ issues or
15
+ # issues that either have no approvers required or are fully approved.
16
+ #
17
+ # Called at the top of every forward transition (+take!+, +mark_in_review!+, +request_testing!+,
18
+ # +mark_ready_for_release!+). Batch / CI-driven transitions (+start_deployment!+, +complete_deployment!+) and
19
+ # reverse transitions (+remove!+) are intentionally NOT gated -- earlier forward transitions already required
20
+ # approval, and gating batch/automated paths would abort entire deploys on a single approval revoke.
21
+ #
22
+ # @raise [PlanMyStuff::PendingApprovalsError] when +issue+ has pending approvals
23
+ #
24
+ # @param issue [PlanMyStuff::Issue, nil]
25
+ #
26
+ # @return [void]
27
+ #
28
+ def guard_approvals!(issue)
29
+ return if issue.nil?
30
+
31
+ return unless issue.approvals_required?
32
+
33
+ return if issue.fully_approved?
34
+
35
+ raise(PlanMyStuff::PendingApprovalsError.new(
36
+ issue: issue,
37
+ pending_count: issue.pending_approvals.count,
38
+ ))
39
+ end
40
+
41
+ # Resolves a canonical status name to its configured display alias, falling back to the canonical name when no
42
+ # alias is configured.
43
+ #
44
+ # @param canonical [String] one of the +Pipeline::Status+ constants
45
+ #
46
+ # @return [String]
47
+ #
48
+ def resolve_status_name(canonical)
49
+ PlanMyStuff.configuration.pipeline_statuses.fetch(canonical, canonical)
50
+ end
51
+
52
+ # Fires a +pipeline_<event>.plan_my_stuff+ notification.
53
+ #
54
+ # When +event+ is a canonical +Pipeline::Status+ name, the event key is derived via +Status.key_for+ and the
55
+ # canonical status is added to the payload as +:status+. Otherwise +event+ is used verbatim as the suffix (e.g.
56
+ # +"removed"+, +"removed_late"+, +"testing"+).
57
+ #
58
+ # +resource+ is normally a single project item (payload carries +:issue_number+). Pass an +Array+ for a batch
59
+ # event (e.g. the deployment-completed sweep) and the payload instead carries +:issue_numbers+ -- the linked
60
+ # number of every item.
61
+ #
62
+ # @param event [String] status name or literal event suffix
63
+ # @param resource [PlanMyStuff::BaseProjectItem, Array<PlanMyStuff::BaseProjectItem>] item or batch of items
64
+ # @param extra [Hash] additional payload entries
65
+ #
66
+ # @return [void]
67
+ #
68
+ def instrument(event, resource, **extra)
69
+ extra_to_use = { **extra }
70
+ event_to_use = event
71
+ if PlanMyStuff::Pipeline::Status::ALL.include?(event_to_use)
72
+ extra_to_use = { status: event, **extra_to_use }
73
+ event_to_use = PlanMyStuff::Pipeline::Status.key_for(event_to_use)
74
+ end
75
+
76
+ number_fields =
77
+ if resource.is_a?(Array)
78
+ { issue_numbers: resource.map(&:number) }
79
+ else
80
+ { issue_number: resource.number }
81
+ end
82
+
83
+ PlanMyStuff::Notifications.instrument(
84
+ "pipeline_#{event_to_use}",
85
+ resource,
86
+ **number_fields,
87
+ **extra_to_use,
88
+ )
89
+ end
90
+
91
+ # Returns the pipeline project number from the explicit argument, +pipeline_project_number+, or
92
+ # +default_project_number+.
93
+ #
94
+ # @raise [ArgumentError] if no project number can be resolved
95
+ #
96
+ # @param project_number [Integer, nil]
97
+ #
98
+ # @return [Integer]
99
+ #
100
+ def resolve_pipeline_project_number!(project_number = nil)
101
+ config = PlanMyStuff.configuration
102
+ result = project_number || config.pipeline_project_number || config.default_project_number
103
+
104
+ raise(ArgumentError, 'No pipeline project number configured') if result.nil?
105
+
106
+ result
107
+ end
108
+
109
+ # Removes a project item from the pipeline project entirely. Captures the prior status before deletion so
110
+ # subscribers can decide whether the removal happened late in the lifecycle.
111
+ #
112
+ # Always fires +pipeline_removed.plan_my_stuff+. Additionally fires +pipeline_removed_late.plan_my_stuff+ when
113
+ # +prior_status+ is past "Started" (i.e. anything other than "Started", accounting for configured status aliases).
114
+ # +nil+ status is treated as not-late.
115
+ #
116
+ # @param project_item [PlanMyStuff::ProjectItem]
117
+ #
118
+ # @return [String, nil] the deleted item ID, or nil if already destroyed
119
+ #
120
+ def remove!(project_item)
121
+ prior_status = project_item.status
122
+ result = project_item.destroy!
123
+
124
+ instrument('removed', project_item, prior_status: prior_status)
125
+ instrument('removed_late', project_item, prior_status: prior_status) if late_removal?(prior_status)
126
+
127
+ result
128
+ end
129
+
130
+ # Returns true when +prior_status+ is past "Started" in the pipeline. Honors configured status aliases. +nil+ is
131
+ # treated as not-late.
132
+ #
133
+ # @param prior_status [String, nil]
134
+ #
135
+ # @return [Boolean]
136
+ #
137
+ def late_removal?(prior_status)
138
+ return false if prior_status.nil?
139
+
140
+ started = resolve_status_name(PlanMyStuff::Pipeline::Status::STARTED)
141
+ prior_status != started
142
+ end
143
+
144
+ # Returns true when +project_item+ is at a release-cycle status ("Ready for Release", "Release in Progress", or
145
+ # "Completed"). Honors configured status aliases.
146
+ #
147
+ # Used to lock items against webhook-driven removals once they enter the release path -- a stray unassign should
148
+ # not yank an item out of "Release in Progress".
149
+ #
150
+ # @param project_item [PlanMyStuff::BaseProjectItem]
151
+ #
152
+ # @return [Boolean]
153
+ #
154
+ def release_cycle_locked?(project_item)
155
+ locked_statuses = [
156
+ resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE),
157
+ resolve_status_name(PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS),
158
+ resolve_status_name(PlanMyStuff::Pipeline::Status::COMPLETED),
159
+ ]
160
+
161
+ locked_statuses.include?(project_item.status)
162
+ end
163
+
164
+ # Moves a project item to "Started". When +user+ is a support user, also stamps +metadata.responded_at+ on the
165
+ # issue via +Issue#mark_responded!+ (a no-op if it's already responded to / not a PMS issue).
166
+ #
167
+ # @param project_item [PlanMyStuff::ProjectItem]
168
+ # @param user [Object, nil] actor forwarded to the +pipeline_started.plan_my_stuff+ payload
169
+ #
170
+ # @return [Hash] mutation result
171
+ #
172
+ def take!(project_item, user: nil)
173
+ guard_approvals!(project_item&.issue)
174
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::STARTED)
175
+ result = project_item.move_to!(status)
176
+
177
+ project_item.issue.mark_responded!(user) if user.present?
178
+
179
+ instrument(PlanMyStuff::Pipeline::Status::STARTED, project_item, user: user)
180
+ result
181
+ end
182
+
183
+ # Moves a project item to "In Review".
184
+ #
185
+ # @param project_item [PlanMyStuff::ProjectItem]
186
+ #
187
+ # @return [Hash] mutation result
188
+ #
189
+ def mark_in_review!(project_item)
190
+ guard_approvals!(project_item&.issue)
191
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::IN_REVIEW)
192
+ result = project_item.move_to!(status)
193
+
194
+ instrument(PlanMyStuff::Pipeline::Status::IN_REVIEW, project_item)
195
+ result
196
+ end
197
+
198
+ # Marks a project item as in testing by setting the +Testing+ custom single-select field to its active value. Does
199
+ # NOT change the item's +Status+ -- testing runs orthogonally to +In Review+.
200
+ #
201
+ # @param project_item [PlanMyStuff::ProjectItem]
202
+ # @param user [Object, nil] actor forwarded to the +pipeline_testing.plan_my_stuff+ payload
203
+ #
204
+ # @return [Hash] mutation result
205
+ #
206
+ def request_testing!(project_item, user: nil)
207
+ guard_approvals!(project_item&.issue)
208
+ field_name = PlanMyStuff.configuration.pipeline_testing_field_name
209
+ value = PlanMyStuff.configuration.pipeline_testing_values.fetch(:active)
210
+ result = project_item.update_single_select_field!(field_name, value)
211
+
212
+ instrument('testing', project_item, field_name: field_name, value: value, user: user)
213
+
214
+ result
215
+ end
216
+
217
+ # Reverses +request_testing!+ by flipping the +Testing+ custom single-select field back to its inactive value.
218
+ # Not approval-gated -- the forward transition (+request_testing!+) was already gated, so clearing should always
219
+ # succeed (matches +remove!+).
220
+ #
221
+ # @param project_item [PlanMyStuff::ProjectItem]
222
+ # @param user [Object, nil] actor forwarded to the +pipeline_testing_cleared.plan_my_stuff+ payload
223
+ #
224
+ # @return [Hash] mutation result
225
+ #
226
+ def clear_testing!(project_item, user: nil)
227
+ field_name = PlanMyStuff.configuration.pipeline_testing_field_name
228
+ value = PlanMyStuff.configuration.pipeline_testing_values.fetch(:inactive)
229
+ result = project_item.update_single_select_field!(field_name, value)
230
+
231
+ instrument('testing_cleared', project_item, field_name: field_name, value: value, user: user)
232
+
233
+ result
234
+ end
235
+
236
+ # Moves a project item to "Ready for Release".
237
+ #
238
+ # @param project_item [PlanMyStuff::ProjectItem]
239
+ #
240
+ # @return [Hash] mutation result
241
+ #
242
+ def mark_ready_for_release!(project_item)
243
+ guard_approvals!(project_item&.issue)
244
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE)
245
+ result = project_item.move_to!(status)
246
+
247
+ instrument(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE, project_item)
248
+ result
249
+ end
250
+
251
+ # Finds ALL items at "Ready for Release" in the pipeline project and moves each to "Release in Progress". Fires an
252
+ # event per item.
253
+ #
254
+ # When +commit_sha+ is given (the merge_commit_sha of the PR into +production+), it is stamped onto every moved
255
+ # item's linked issue metadata. +AwsController#handle_deployment_completed+ later matches this sha against the
256
+ # configured +production_commit_sha+ to decide which items to auto-complete, so this is the only commit sha in the
257
+ # release cycle that matters.
258
+ #
259
+ # @param project_number [Integer, nil]
260
+ # @param commit_sha [String, nil]
261
+ #
262
+ # @return [Array<PlanMyStuff::ProjectItem>]
263
+ #
264
+ def start_deployment!(project_number: nil, commit_sha: nil)
265
+ number = resolve_pipeline_project_number!(project_number)
266
+ project = PlanMyStuff::Project.find(number)
267
+
268
+ ready_status = resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE)
269
+ in_progress_status = resolve_status_name(PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS)
270
+
271
+ items = project.items.select { |item| item.status == ready_status }
272
+
273
+ items.each do |item|
274
+ if commit_sha.present?
275
+ issue = item.issue
276
+ issue.metadata.commit_sha = commit_sha
277
+ issue.save!
278
+ end
279
+
280
+ item.move_to!(in_progress_status)
281
+ instrument(PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS, item, commit_sha: commit_sha)
282
+ end
283
+
284
+ items
285
+ end
286
+
287
+ # Moves a project item to "Completed" if the linked issue has +auto_complete+ enabled, and sets the linked issue's
288
+ # "Issue Status" field to "Fixed". Returns +nil+ when auto-complete is off (item stays at "Release in Progress" and
289
+ # no field is touched). The "Issue Status" update is skipped when +config.issue_fields_enabled+ is +false+ so
290
+ # completion still succeeds.
291
+ #
292
+ # @param project_item [PlanMyStuff::ProjectItem]
293
+ # @param deployment_id [String, nil]
294
+ #
295
+ # @return [Hash, nil] mutation result or nil
296
+ #
297
+ def complete_deployment!(project_item, deployment_id: nil)
298
+ issue = project_item.issue
299
+ return unless issue.metadata.auto_complete?
300
+
301
+ status = resolve_status_name(PlanMyStuff::Pipeline::Status::COMPLETED)
302
+ result = project_item.move_to!(status)
303
+
304
+ issue.set_issue_fields!('Issue Status' => 'Fixed') if PlanMyStuff.configuration.issue_fields_enabled
305
+
306
+ instrument(PlanMyStuff::Pipeline::Status::COMPLETED, project_item, deployment_id: deployment_id)
307
+ result
308
+ end
309
+ end
310
+ end