plan_my_stuff 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a70108268136db83554f3bda05e5ab384b1d2c6f1d8d05f4554cef037eeee8a8
4
- data.tar.gz: 5c713eca795782628fab95c61c3d5876934d22b04e0c539e26d35369a376491b
3
+ metadata.gz: 69661ace546ff5f775e0e9fb5e4a153c3aca9e68da0b568e85238d8b66ef620a
4
+ data.tar.gz: e0698c727fe6c3f1e8e135d0a2b1860731188dc01496065e3622a0a1d6ef0c45
5
5
  SHA512:
6
- metadata.gz: 253f04b4c10e8d1557d5915daa1a757d5678c717cd4b7b97d50450ed4e8417c05f044bbf97c235de17328a4fd3ee27f8a08bbaf1b874ce0dccd294760f370114
7
- data.tar.gz: 331d2a9b48418722eb3049099817e390095c07deb70e59670ead47cbf98555bb2c44e12b1cdc9824abd28fd6795c80fa8aac124ea5779dd9262323a7bc96c10a
6
+ metadata.gz: 876ac7c9c1baf371e09d81535324ae9a35bcd1c3a30509931395e274a229fcf25397ba1377450cb6a8ad6465f0474174ea664579b59f0d407c9b83ee08523647
7
+ data.tar.gz: 89c4a1865f38c614c05d802c02d267b0ff2e83339da936403fac08f3d98476a6b87b722eecf422b5ee8f101f5db1ce75e8d3f836a3f54dd0ca86713df3146feb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Breaking
6
+
7
+ - `Pipeline.submit!` removed; consuming apps that called it directly should switch to `ProjectItem.create!` + `Pipeline.take!`
8
+ - `Pipeline::Status::SUBMITTED` and `Pipeline::Status::TESTING` constants removed; `Status::ALL` no longer includes them
9
+ - `Pipeline.request_testing!` no longer moves the `Status` field — it now writes to a separate `Testing` single-select custom field on the pipeline project. The pipeline project must have a `Testing` field with `Testing` / `Not testing` options
10
+ - Active pipeline status set is now `Started → In Review → Ready for Release → Release in Progress → Completed`
11
+
12
+ ### Added
13
+
14
+ - `Pipeline::Testing` module (`FIELD_NAME`, `VALUES`)
15
+ - `Pipeline::CompletedSweep` removes items from `Completed` after the configured TTL; driven by `RemindersSweepJob`
16
+ - `BaseProjectItem#update_single_select_field!` instance method
17
+ - `BaseProjectItem#updated_at` populated from GitHub's `updatedAt`
18
+ - `Issue#assignees` public reader
19
+ - `Pipeline.release_cycle_locked?` helper
20
+ - New config: `pipeline_testing_field_name`, `pipeline_testing_values`, `pipeline_completion_purge_enabled` (default `true`), `pipeline_completion_ttl_hours` (default `24`)
21
+
22
+ ### Changed
23
+
24
+ - Assigning a dev to an issue now creates the project item and lands it at `Started` (was `Submitted`)
25
+ - Opening a PR as a draft adds the linked issue to the pipeline at `Started` and assigns the PR author when the issue has no assignees; already-pipelined items are not moved
26
+ - The assignment webhook no longer calls `assign!` on the project item — GitHub already records the assignment, and `assign!` could clobber co-assignees
27
+
28
+ ### Removed
29
+
30
+ - `Pipeline.submit!` (see Breaking)
31
+ - `Pipeline::Status::SUBMITTED`, `Pipeline::Status::TESTING` (see Breaking)
32
+
33
+ ### Fixed
34
+
35
+ - Closed issues no longer alter the pipeline on assign/unassign webhook events
36
+ - Items at `Ready for Release`, `Release in Progress`, or `Completed` are no longer removed when an unassign webhook fires
37
+ - Pending-approvals guard runs before `ProjectItem.create!` in the assignment flow so the webhook no longer leaves orphan items when `Pipeline.take!`'s approval guard would otherwise raise
38
+
39
+ ### Documented
40
+
41
+ - `designs/pipeline_cleanup/plan.md` — full design + bug-to-fix mapping
42
+ - `requirements/08_release_pipeline.md` updated to reflect the new flow
43
+ - `requirements/tasks.md` — T-PC-001 added under Phase 14
44
+
45
+ ## 0.5.1
46
+
47
+ ### Fixed
48
+
49
+ - AWS webhook rejected SNS `SubscriptionConfirmation` and `UnsubscribeConfirmation` messages with 401; now signature-verified, logged, and acked with 200
50
+
3
51
  ## 0.5.0
4
52
 
5
53
  ### Added
@@ -3,13 +3,22 @@
3
3
  module PlanMyStuff
4
4
  module Webhooks
5
5
  class AwsController < ActionController::API
6
- VALID_SNS_MESSAGE_TYPES = %w[Notification].freeze
6
+ VALID_SNS_MESSAGE_TYPES = %w[Notification SubscriptionConfirmation UnsubscribeConfirmation].freeze
7
7
 
8
8
  before_action :verify_signature!
9
9
 
10
10
  # POST /webhooks/aws
11
11
  def create
12
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
+ unless message_type == 'Notification'
18
+ head(:ok)
19
+
20
+ return
21
+ end
13
22
 
14
23
  unless config.process_aws_webhooks
15
24
  head(:ok)
@@ -81,14 +81,27 @@ module PlanMyStuff
81
81
  end
82
82
  end
83
83
 
84
- # Adds the issue to the pipeline project at "Submitted" the first
84
+ # Adds the issue to the pipeline project at "Started" the first
85
85
  # time it is assigned. Re-assigns and additional assignees are
86
86
  # ignored -- if the issue is already in the pipeline we do not
87
87
  # touch its status.
88
88
  #
89
+ # Skipped paths (no project item is created):
90
+ # - Issue is closed (assignment changes on a closed issue
91
+ # must not alter the pipeline)
92
+ # - Issue has pending approvals (creating + +take!+ would
93
+ # either orphan an item or 500 on the approval guard)
94
+ #
95
+ # GitHub already records the issue assignment (that's what
96
+ # triggered this webhook), so the gem does not call +assign!+
97
+ # on the project item -- that would clobber co-assignees on
98
+ # the underlying issue.
99
+ #
89
100
  # @return [void]
90
101
  #
91
102
  def handle_issue_assigned
103
+ return if issue_params[:state] == 'closed'
104
+
92
105
  issue_number = issue_params.fetch(:number)
93
106
  assignee_login = payload_params.dig(:assignee, :login)
94
107
 
@@ -96,14 +109,23 @@ module PlanMyStuff
96
109
 
97
110
  existing = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
98
111
  if existing.present?
99
- Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} already in pipeline project, skipping submit!")
112
+ Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} already in pipeline project, skipping")
100
113
 
101
114
  return
102
115
  end
103
116
 
104
117
  repo = payload_params.dig(:repository, :full_name)
105
118
  issue = PlanMyStuff::Issue.find(issue_number, repo: repo)
106
- PlanMyStuff::Pipeline.submit!(issue, assignee: assignee_login)
119
+
120
+ if issue.approvals_required? && !issue.fully_approved?
121
+ Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} has pending approvals, skipping")
122
+
123
+ return
124
+ end
125
+
126
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number
127
+ project_item = PlanMyStuff::ProjectItem.create!(issue, project_number: number)
128
+ PlanMyStuff::Pipeline.take!(project_item)
107
129
  end
108
130
 
109
131
  # Removes the issue from the pipeline project when the LAST
@@ -111,9 +133,17 @@ module PlanMyStuff
111
133
  # a no-op (only one of N was removed). If the issue isn't in
112
134
  # the pipeline at all, also a no-op (logged at info).
113
135
  #
136
+ # Closed issues are skipped: assignment changes on a closed
137
+ # issue must not alter the pipeline. Items already on the
138
+ # release cycle (Ready for Release, Release in Progress,
139
+ # Completed) are also locked -- once on the release path an
140
+ # item should not come off via webhook.
141
+ #
114
142
  # @return [void]
115
143
  #
116
144
  def handle_issue_unassigned
145
+ return if issue_params[:state] == 'closed'
146
+
117
147
  remaining_assignees = Array.wrap(issue_params[:assignees])
118
148
  return if remaining_assignees.any?
119
149
 
@@ -128,6 +158,14 @@ module PlanMyStuff
128
158
  return
129
159
  end
130
160
 
161
+ if PlanMyStuff::Pipeline.release_cycle_locked?(project_item)
162
+ Rails.logger.info(
163
+ "[PlanMyStuff] Issue ##{issue_number} on release cycle (#{project_item.status}), skipping remove!",
164
+ )
165
+
166
+ return
167
+ end
168
+
131
169
  PlanMyStuff::Pipeline.remove!(project_item)
132
170
  end
133
171
 
@@ -205,7 +243,7 @@ module PlanMyStuff
205
243
 
206
244
  case action
207
245
  when 'opened'
208
- pull_request_params[:draft] ? handle_converted_to_draft : handle_ready_for_review
246
+ pull_request_params[:draft] ? handle_draft_opened : handle_ready_for_review
209
247
  when 'ready_for_review', 'reopened'
210
248
  handle_ready_for_review
211
249
  when 'converted_to_draft'
@@ -215,6 +253,29 @@ module PlanMyStuff
215
253
  end
216
254
  end
217
255
 
256
+ # Opening a PR as a draft is a soft "I've started working on this"
257
+ # signal. For each linked issue, when the issue is not yet in the
258
+ # pipeline, add it at "Started"; when the issue has no assignees,
259
+ # assign the PR author. Already-in-pipeline items are NOT moved
260
+ # (a draft open is not a status change).
261
+ #
262
+ # @return [void]
263
+ #
264
+ def handle_draft_opened
265
+ repo = payload_params.dig(:repository, :full_name)
266
+ pr_author = pull_request_params.dig(:user, :login)
267
+
268
+ each_linked_issue_number do |issue_number|
269
+ issue = PlanMyStuff::Issue.find(issue_number, repo: repo)
270
+
271
+ if PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number).nil?
272
+ ensure_in_pipeline_at_started(issue)
273
+ end
274
+
275
+ assign_pr_author(issue, pr_author) if pr_author.present? && issue.assignees.empty?
276
+ end
277
+ end
278
+
218
279
  # @return [void]
219
280
  def handle_ready_for_review
220
281
  each_linked_item do |item|
@@ -222,6 +283,34 @@ module PlanMyStuff
222
283
  end
223
284
  end
224
285
 
286
+ # Adds the issue to the pipeline at "Started". Skipped when the
287
+ # issue has pending approvals (Pipeline.take!'s guard would
288
+ # otherwise leave an orphan project item behind).
289
+ #
290
+ # @param issue [PlanMyStuff::Issue]
291
+ #
292
+ # @return [void]
293
+ #
294
+ def ensure_in_pipeline_at_started(issue)
295
+ if issue.approvals_required? && !issue.fully_approved?
296
+ Rails.logger.info("[PlanMyStuff] Issue ##{issue.number} has pending approvals, skipping pipeline add")
297
+ return
298
+ end
299
+
300
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number
301
+ project_item = PlanMyStuff::ProjectItem.create!(issue, project_number: number)
302
+ PlanMyStuff::Pipeline.take!(project_item)
303
+ end
304
+
305
+ # @param issue [PlanMyStuff::Issue]
306
+ # @param pr_author [String]
307
+ #
308
+ # @return [void]
309
+ #
310
+ def assign_pr_author(issue, pr_author)
311
+ PlanMyStuff::Issue.update!(number: issue.number, repo: issue.repo, assignees: [pr_author])
312
+ end
313
+
225
314
  # @return [void]
226
315
  def handle_converted_to_draft
227
316
  each_linked_item do |item|
@@ -247,13 +336,7 @@ module PlanMyStuff
247
336
 
248
337
  # @yield [PlanMyStuff::ProjectItem]
249
338
  def each_linked_item
250
- messages = fetch_commit_messages
251
- numbers = PlanMyStuff::Pipeline::IssueLinker.extract_issue_numbers(
252
- pull_request_params,
253
- commit_messages: messages,
254
- )
255
-
256
- numbers.each do |issue_number|
339
+ each_linked_issue_number do |issue_number|
257
340
  item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
258
341
 
259
342
  if item
@@ -264,6 +347,17 @@ module PlanMyStuff
264
347
  end
265
348
  end
266
349
 
350
+ # @yield [Integer] linked issue number
351
+ def each_linked_issue_number(&)
352
+ messages = fetch_commit_messages
353
+ numbers = PlanMyStuff::Pipeline::IssueLinker.extract_issue_numbers(
354
+ pull_request_params,
355
+ commit_messages: messages,
356
+ )
357
+
358
+ numbers.each(&)
359
+ end
360
+
267
361
  # Fetches commit messages for the current PR via the GitHub API.
268
362
  #
269
363
  # @return [Array<String>] commit messages (empty on failure)
@@ -54,6 +54,7 @@ module PlanMyStuff
54
54
  @repo_arg = repo
55
55
  PlanMyStuff::Reminders::Sweep.new(repo: repo).call
56
56
  PlanMyStuff::Archive::Sweep.new(repo: repo).call
57
+ PlanMyStuff::Pipeline::CompletedSweep.perform
57
58
  end
58
59
 
59
60
  private
@@ -17,7 +17,7 @@
17
17
  <%= button_to('Mark waiting', plan_my_stuff.issue_waiting_path(@issue.number, repo: @issue.repo.full_name), method: :post) %>
18
18
  <% end %>
19
19
  <% end %>
20
- <% if @support_user && @pipeline_enabled && (@pipeline_item.nil? || @pipeline_item.status == PlanMyStuff::Pipeline.resolve_status_name(PlanMyStuff::Pipeline::Status::SUBMITTED)) %>
20
+ <% if @support_user && @pipeline_enabled && @pipeline_item.nil? %>
21
21
  <%= button_to('Take', plan_my_stuff.issue_take_path(@issue.number, repo: @issue.repo.full_name), method: :post) %>
22
22
  <% end %>
23
23
  <% if @support_user %>
@@ -331,6 +331,7 @@ module PlanMyStuff
331
331
  repo: repo_name.present? ? PlanMyStuff::Repo.resolve(repo_name) : nil,
332
332
  status: extract_item_status(field_values),
333
333
  field_values: parse_field_values(field_values),
334
+ updated_at: item[:updatedAt],
334
335
  github_response: item,
335
336
  }
336
337
  end
@@ -43,6 +43,8 @@ module PlanMyStuff
43
43
  attribute :state, :string
44
44
  # @return [String, nil]
45
45
  attribute :status, :string
46
+ # @return [Time, nil] GitHub's updatedAt timestamp
47
+ attribute :updated_at, :datetime
46
48
  # @return [Hash]
47
49
  attribute :field_values, default: -> { {} }
48
50
  # @return [PlanMyStuff::BaseProject, nil]
@@ -477,6 +479,22 @@ module PlanMyStuff
477
479
  )
478
480
  end
479
481
 
482
+ # Updates a single-select custom field on this item.
483
+ #
484
+ # @param field_name [String] single-select field name (e.g. "Testing")
485
+ # @param value [String] option name to select
486
+ #
487
+ # @return [Hash] mutation result
488
+ #
489
+ def update_single_select_field!(field_name, value)
490
+ self.class.update_single_select_field!(
491
+ project_number: project.number,
492
+ item_id: id,
493
+ field_name: field_name,
494
+ value: value,
495
+ )
496
+ end
497
+
480
498
  # Deletes this item from its parent project. Marks the in-memory
481
499
  # instance as destroyed so +destroyed?+ returns true and +persisted?+
482
500
  # returns false.
@@ -164,12 +164,27 @@ module PlanMyStuff
164
164
  attr_accessor :production_commit_sha
165
165
 
166
166
  # Canonical status name to display alias map. Allows consuming apps to rename
167
- # pipeline statuses (e.g. "Submitted" to "Triaged").
167
+ # pipeline statuses (e.g. "Started" to "In Progress").
168
168
  #
169
169
  # @return [Hash{String => String}]
170
170
  #
171
171
  attr_accessor :pipeline_statuses
172
172
 
173
+ # Display name for the +Testing+ single-select custom field on the
174
+ # pipeline project. Defaults to +"Testing"+.
175
+ #
176
+ # @return [String]
177
+ #
178
+ attr_accessor :pipeline_testing_field_name
179
+
180
+ # Map of canonical testing field option keys (+:active+, +:inactive+)
181
+ # to display labels. Allows consuming apps to rename the option labels
182
+ # without changing the canonical identifiers.
183
+ #
184
+ # @return [Hash{Symbol => String}]
185
+ #
186
+ attr_accessor :pipeline_testing_values
187
+
173
188
  # @return [String] branch name that PRs merge into for "Ready for release" transition
174
189
  attr_accessor :main_branch
175
190
 
@@ -281,6 +296,21 @@ module PlanMyStuff
281
296
  #
282
297
  attr_accessor :archived_label
283
298
 
299
+ # Whether the pipeline sweep removes aged-out +Completed+ items.
300
+ # Defaults to +true+. Set to +false+ to keep items in +Completed+
301
+ # indefinitely.
302
+ #
303
+ # @return [Boolean]
304
+ #
305
+ attr_accessor :pipeline_completion_purge_enabled
306
+
307
+ # Hours after a project item's last update at which the sweep removes
308
+ # it from the pipeline if its status is +Completed+. Defaults to +24+.
309
+ #
310
+ # @return [Integer]
311
+ #
312
+ attr_accessor :pipeline_completion_ttl_hours
313
+
284
314
  # Whether to process incoming AWS webhook events. Defaults to +Rails.env.production?+.
285
315
  #
286
316
  # @return [Boolean]
@@ -323,6 +353,8 @@ module PlanMyStuff
323
353
  @testing_custom_fields = {}
324
354
  @pipeline_enabled = true
325
355
  @pipeline_statuses = {}
356
+ @pipeline_testing_field_name = PlanMyStuff::Pipeline::Testing::FIELD_NAME
357
+ @pipeline_testing_values = PlanMyStuff::Pipeline::Testing::VALUES.dup
326
358
  @main_branch = 'main'
327
359
  @production_branch = 'production'
328
360
  @mount_groups = { webhooks: true, issues: true, projects: true }
@@ -338,6 +370,8 @@ module PlanMyStuff
338
370
  @archiving_enabled = true
339
371
  @archive_closed_after_days = 90
340
372
  @archived_label = 'archived'
373
+ @pipeline_completion_purge_enabled = true
374
+ @pipeline_completion_ttl_hours = 24
341
375
  @process_aws_webhooks = Rails.env.production?
342
376
  @sns_verifier_class = ::Aws::SNS::MessageVerifier if defined?(::Aws::SNS::MessageVerifier)
343
377
  @sns_verifier_error =
@@ -103,6 +103,7 @@ module PlanMyStuff
103
103
  nodes {
104
104
  id
105
105
  type
106
+ updatedAt
106
107
  content {
107
108
  ... on Issue {
108
109
  id
@@ -689,6 +689,14 @@ module PlanMyStuff
689
689
  safe_read_field(github_response, :html_url)
690
690
  end
691
691
 
692
+ # GitHub assignees for this issue, by login.
693
+ #
694
+ # @return [Array<String>]
695
+ #
696
+ def assignees
697
+ extract_assignee_logins(github_response)
698
+ end
699
+
692
700
  # @return [Boolean]
693
701
  def pms_issue?
694
702
  metadata.schema_version.present?
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Pipeline
5
+ # Removes pipeline project items that have been at +Completed+ for
6
+ # longer than the configured TTL.
7
+ #
8
+ # Driven by +RemindersSweepJob+ so the gem ships one scheduled
9
+ # entrypoint. Items are removed via +Pipeline.remove!+ so subscribers
10
+ # see the standard +plan_my_stuff.pipeline.removed+ event.
11
+ #
12
+ module CompletedSweep
13
+ module_function
14
+
15
+ # Runs the sweep. No-op when
16
+ # +configuration.pipeline_completion_purge_enabled+ is false.
17
+ #
18
+ # @param project_number [Integer, nil]
19
+ # @param now [Time]
20
+ #
21
+ # @return [Array<PlanMyStuff::ProjectItem>] removed items
22
+ #
23
+ def perform(project_number: nil, now: Time.current)
24
+ config = PlanMyStuff.configuration
25
+ return [] unless config.pipeline_completion_purge_enabled
26
+
27
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number(project_number)
28
+ project = PlanMyStuff::Project.find(number)
29
+
30
+ completed_status = PlanMyStuff::Pipeline.resolve_status_name(
31
+ PlanMyStuff::Pipeline::Status::COMPLETED,
32
+ )
33
+ ttl = config.pipeline_completion_ttl_hours.hours
34
+
35
+ candidates = project.items.select do |item|
36
+ item.status == completed_status &&
37
+ item.updated_at.present? &&
38
+ item.updated_at + ttl < now
39
+ end
40
+
41
+ candidates.each { |item| PlanMyStuff::Pipeline.remove!(item) }
42
+ candidates
43
+ end
44
+ end
45
+ end
46
+ end
@@ -10,20 +10,16 @@ module PlanMyStuff
10
10
  # but the canonical names here remain the internal identifiers.
11
11
  #
12
12
  module Status
13
- SUBMITTED = 'Submitted'
14
13
  STARTED = 'Started'
15
14
  IN_REVIEW = 'In Review'
16
- TESTING = 'Testing'
17
15
  READY_FOR_RELEASE = 'Ready for Release'
18
16
  RELEASE_IN_PROGRESS = 'Release in Progress'
19
17
  COMPLETED = 'Completed'
20
18
 
21
19
  # All statuses in pipeline order (frozen).
22
20
  ALL = [
23
- SUBMITTED,
24
21
  STARTED,
25
22
  IN_REVIEW,
26
- TESTING,
27
23
  READY_FOR_RELEASE,
28
24
  RELEASE_IN_PROGRESS,
29
25
  COMPLETED,
@@ -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
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/notifications'
4
+ require_relative 'pipeline/completed_sweep'
4
5
  require_relative 'pipeline/issue_linker'
5
6
  require_relative 'pipeline/status'
7
+ require_relative 'pipeline/testing'
6
8
 
7
9
  module PlanMyStuff
8
10
  # High-level orchestration layer for the release pipeline.
@@ -20,7 +22,7 @@ module PlanMyStuff
20
22
  # pending manager approvals. No-op for +nil+ issues or issues that
21
23
  # either have no approvers required or are fully approved.
22
24
  #
23
- # Called at the top of every forward transition (+submit!+, +take!+,
25
+ # Called at the top of every forward transition (+take!+,
24
26
  # +mark_in_review!+, +request_testing!+,
25
27
  # +mark_ready_for_release!+). Batch / CI-driven transitions
26
28
  # (+start_deployment!+, +complete_deployment!+) and reverse
@@ -95,40 +97,14 @@ module PlanMyStuff
95
97
  result
96
98
  end
97
99
 
98
- # Adds an issue to the pipeline project, moves it to "Submitted",
99
- # and assigns the given developer.
100
- #
101
- # Called when an issue is assigned to a dev, NOT on issue creation.
102
- # Un-triaged issues should not be in the pipeline.
103
- #
104
- # @param issue [PlanMyStuff::Issue]
105
- # @param assignee [String] GitHub username
106
- # @param project_number [Integer, nil]
107
- #
108
- # @return [PlanMyStuff::ProjectItem]
109
- #
110
- def submit!(issue, assignee:, project_number: nil)
111
- guard_approvals!(issue)
112
- number = resolve_pipeline_project_number(project_number)
113
- project_item = PlanMyStuff::ProjectItem.create!(issue, project_number: number)
114
-
115
- status = resolve_status_name(PlanMyStuff::Pipeline::Status::SUBMITTED)
116
- project_item.move_to!(status)
117
- project_item.assign!(assignee)
118
-
119
- instrument(PlanMyStuff::Pipeline::Status::SUBMITTED, project_item)
120
- project_item
121
- end
122
-
123
100
  # Removes a project item from the pipeline project entirely.
124
101
  # Captures the prior status before deletion so subscribers can
125
102
  # decide whether the removal happened late in the lifecycle.
126
103
  #
127
104
  # Always fires +plan_my_stuff.pipeline.removed+. Additionally fires
128
105
  # +plan_my_stuff.pipeline.removed_late+ when +prior_status+ is past
129
- # "Started" (i.e. anything other than "Submitted" or "Started",
130
- # accounting for configured status aliases). +nil+ status is treated
131
- # as not-late.
106
+ # "Started" (i.e. anything other than "Started", accounting for
107
+ # configured status aliases). +nil+ status is treated as not-late.
132
108
  #
133
109
  # @param project_item [PlanMyStuff::ProjectItem]
134
110
  #
@@ -164,12 +140,30 @@ module PlanMyStuff
164
140
  def late_removal?(prior_status)
165
141
  return false if prior_status.nil?
166
142
 
167
- early_statuses = [
168
- resolve_status_name(PlanMyStuff::Pipeline::Status::SUBMITTED),
169
- resolve_status_name(PlanMyStuff::Pipeline::Status::STARTED),
143
+ started = resolve_status_name(PlanMyStuff::Pipeline::Status::STARTED)
144
+ prior_status != started
145
+ end
146
+
147
+ # Returns true when +project_item+ is at a release-cycle status
148
+ # ("Ready for Release", "Release in Progress", or "Completed").
149
+ # Honors configured status aliases.
150
+ #
151
+ # Used to lock items against webhook-driven removals once they
152
+ # enter the release path -- a stray unassign should not yank an
153
+ # item out of "Release in Progress".
154
+ #
155
+ # @param project_item [PlanMyStuff::BaseProjectItem]
156
+ #
157
+ # @return [Boolean]
158
+ #
159
+ def release_cycle_locked?(project_item)
160
+ locked_statuses = [
161
+ resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE),
162
+ resolve_status_name(PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS),
163
+ resolve_status_name(PlanMyStuff::Pipeline::Status::COMPLETED),
170
164
  ]
171
165
 
172
- early_statuses.exclude?(prior_status)
166
+ locked_statuses.include?(project_item.status)
173
167
  end
174
168
 
175
169
  # Moves a project item to "Started".
@@ -202,7 +196,9 @@ module PlanMyStuff
202
196
  result
203
197
  end
204
198
 
205
- # Moves a project item to "Testing".
199
+ # Marks a project item as in testing by setting the +Testing+ custom
200
+ # single-select field to its active value. Does NOT change the item's
201
+ # +Status+ -- testing runs orthogonally to +In Review+.
206
202
  #
207
203
  # @param project_item [PlanMyStuff::ProjectItem]
208
204
  #
@@ -210,10 +206,19 @@ module PlanMyStuff
210
206
  #
211
207
  def request_testing!(project_item)
212
208
  guard_approvals!(project_item&.issue)
213
- status = resolve_status_name(PlanMyStuff::Pipeline::Status::TESTING)
214
- result = project_item.move_to!(status)
209
+ field_name = PlanMyStuff.configuration.pipeline_testing_field_name
210
+ value = PlanMyStuff.configuration.pipeline_testing_values.fetch(:active)
211
+ result = project_item.update_single_select_field!(field_name, value)
212
+
213
+ ActiveSupport::Notifications.instrument(
214
+ 'plan_my_stuff.pipeline.testing',
215
+ project_item: project_item,
216
+ issue_number: project_item.number,
217
+ field_name: field_name,
218
+ value: value,
219
+ timestamp: Time.current,
220
+ )
215
221
 
216
- instrument(PlanMyStuff::Pipeline::Status::TESTING, project_item)
217
222
  result
218
223
  end
219
224
 
@@ -266,11 +266,6 @@ module PlanMyStuff
266
266
  expect_pms_action(:item_assigned, 'item to be assigned', **filters)
267
267
  end
268
268
 
269
- # @param filters [Hash] attribute filters (e.g. assignee:, project_number:)
270
- def expect_pms_pipeline_submitted(**filters)
271
- expect_pms_action(:pipeline_submitted, 'pipeline submission', **filters)
272
- end
273
-
274
269
  # @param filters [Hash] attribute filters (e.g. item_id:)
275
270
  def expect_pms_pipeline_taken(**filters)
276
271
  expect_pms_action(:pipeline_started, 'pipeline item to be taken', **filters)
@@ -612,36 +607,9 @@ module PlanMyStuff
612
607
  def stub_pipeline_methods!
613
608
  pipeline_mod = PlanMyStuff::Pipeline
614
609
  %i[
615
- submit! take! mark_in_review! request_testing! mark_ready_for_release! start_deployment! complete_deployment!
610
+ take! mark_in_review! request_testing! mark_ready_for_release! start_deployment! complete_deployment!
616
611
  ].each { |m| save_original(pipeline_mod, m) }
617
612
 
618
- pipeline_mod.define_singleton_method(:submit!) do |issue, assignee:, project_number: nil|
619
- number =
620
- project_number || PlanMyStuff.configuration.pipeline_project_number ||
621
- PlanMyStuff.configuration.default_project_number
622
-
623
- PlanMyStuff::TestHelpers.recorded_actions << {
624
- type: :pipeline_submitted,
625
- params: {
626
- issue_number: issue.respond_to?(:number) ? issue.number : nil,
627
- assignee: assignee,
628
- project_number: number,
629
- },
630
- }
631
-
632
- project = PlanMyStuff::TestHelpers.build_project(number: number || 1)
633
- PlanMyStuff::ProjectItem.build(
634
- {
635
- id: "PVTI_fake_#{rand(10_000)}",
636
- title: issue.respond_to?(:title) ? issue.title : 'Untitled',
637
- number: issue.respond_to?(:number) ? issue.number : nil,
638
- status: PlanMyStuff::Pipeline::Status::SUBMITTED,
639
- field_values: {},
640
- },
641
- project: project,
642
- )
643
- end
644
-
645
613
  pipeline_mod.define_singleton_method(:take!) do |project_item|
646
614
  PlanMyStuff::TestHelpers.recorded_actions << {
647
615
  type: :pipeline_started,
@@ -667,14 +635,23 @@ module PlanMyStuff
667
635
  end
668
636
 
669
637
  pipeline_mod.define_singleton_method(:request_testing!) do |project_item|
638
+ field_name = PlanMyStuff.configuration.pipeline_testing_field_name
639
+ value = PlanMyStuff.configuration.pipeline_testing_values.fetch(:active)
640
+
670
641
  PlanMyStuff::TestHelpers.recorded_actions << {
671
642
  type: :pipeline_testing,
672
643
  params: {
673
644
  item_id: project_item.respond_to?(:id) ? project_item.id : nil,
674
645
  issue_number: project_item.respond_to?(:number) ? project_item.number : nil,
646
+ field_name: field_name,
647
+ value: value,
675
648
  },
676
649
  }
677
650
 
651
+ if project_item.respond_to?(:field_values) && project_item.field_values.is_a?(Hash)
652
+ project_item.field_values[field_name] = value
653
+ end
654
+
678
655
  nil
679
656
  end
680
657
 
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 5
6
+ MINOR = 6
7
7
  TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plan_my_stuff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brands Insurance
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-24 00:00:00.000000000 Z
11
+ date: 2026-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -130,8 +130,10 @@ files:
130
130
  - lib/plan_my_stuff/metadata_parser.rb
131
131
  - lib/plan_my_stuff/notifications.rb
132
132
  - lib/plan_my_stuff/pipeline.rb
133
+ - lib/plan_my_stuff/pipeline/completed_sweep.rb
133
134
  - lib/plan_my_stuff/pipeline/issue_linker.rb
134
135
  - lib/plan_my_stuff/pipeline/status.rb
136
+ - lib/plan_my_stuff/pipeline/testing.rb
135
137
  - lib/plan_my_stuff/project.rb
136
138
  - lib/plan_my_stuff/project_item.rb
137
139
  - lib/plan_my_stuff/project_item_metadata.rb