plan_my_stuff 0.5.1 → 0.7.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: 37d945d43da806aa982200d92448e6d419a9adc2f7456b5b7b4ac0be7b1a11f2
4
- data.tar.gz: 81ce6e1b7ae595a275bb1aa1f5023fc5a8f830f6d5e0315ebf2aa23b40272a01
3
+ metadata.gz: c4ce40c49bbe2d0ef870cf9983a5571631bdcce7f3ce52c31d5cf9885b143e3b
4
+ data.tar.gz: 8e62775d14932c50fe1b69cf76990345adf697dbdf509603eb7d5b4e35cc982f
5
5
  SHA512:
6
- metadata.gz: 5988a387e487ef49ec8791c7d4a0b3eac2a148c0349d59b1cae2e0a6db455bae1e364d577a9d42be611791af08368fa9c3ea7c1d4d8b7e4e24d518c0502fb680
7
- data.tar.gz: 0e78e8bed60d993c9fa42dfcb6ba3d4d5a8e063ee103232a2b5302598156c720cbdea49b71e2556c0dd6f477a20f41bfbffc5ff546bab438c55e696d6fabc20f
6
+ metadata.gz: cf7fcd9a19a7845293c4b52f69ac7ef0cd9aa966bd0ddeb8851521d842e21f98a6e8ef49996125044bc453d99336ea086852d2d605951d348052a3ce16d2f250
7
+ data.tar.gz: 9f30fdb98fd6977eeb4362fe41f99472b4771299fa123a080bc83900c65bb794f69f19d5cdb1642efb4867435413ce01169014555641264896b1465b54dc6952
data/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Added
6
+
7
+ - `Issue#user_link` returns the per-issue URL in the consuming app, computed from `config.issues_url_prefix` + issue number
8
+
9
+ ### Changed
10
+
11
+ - GitHub issues created/updated by PMS now render a markdown link `[Org/Repo#number](user_link)` as the visible body (previously empty); skipped when `issues_url_prefix` is unset
12
+
13
+ ## 0.6.0
14
+
15
+ ### Breaking
16
+
17
+ - `Pipeline.submit!` removed; consuming apps that called it directly should switch to `ProjectItem.create!` + `Pipeline.take!`
18
+ - `Pipeline::Status::SUBMITTED` and `Pipeline::Status::TESTING` constants removed; `Status::ALL` no longer includes them
19
+ - `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
20
+ - Active pipeline status set is now `Started → In Review → Ready for Release → Release in Progress → Completed`
21
+
22
+ ### Added
23
+
24
+ - `Pipeline::Testing` module (`FIELD_NAME`, `VALUES`)
25
+ - `Pipeline::CompletedSweep` removes items from `Completed` after the configured TTL; driven by `RemindersSweepJob`
26
+ - `BaseProjectItem#update_single_select_field!` instance method
27
+ - `BaseProjectItem#updated_at` populated from GitHub's `updatedAt`
28
+ - `Issue#assignees` public reader
29
+ - `Pipeline.release_cycle_locked?` helper
30
+ - New config: `pipeline_testing_field_name`, `pipeline_testing_values`, `pipeline_completion_purge_enabled` (default `true`), `pipeline_completion_ttl_hours` (default `24`)
31
+
32
+ ### Changed
33
+
34
+ - Assigning a dev to an issue now creates the project item and lands it at `Started` (was `Submitted`)
35
+ - 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
36
+ - The assignment webhook no longer calls `assign!` on the project item — GitHub already records the assignment, and `assign!` could clobber co-assignees
37
+
38
+ ### Removed
39
+
40
+ - `Pipeline.submit!` (see Breaking)
41
+ - `Pipeline::Status::SUBMITTED`, `Pipeline::Status::TESTING` (see Breaking)
42
+
43
+ ### Fixed
44
+
45
+ - Closed issues no longer alter the pipeline on assign/unassign webhook events
46
+ - Items at `Ready for Release`, `Release in Progress`, or `Completed` are no longer removed when an unassign webhook fires
47
+ - 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
48
+
49
+ ### Documented
50
+
51
+ - `designs/pipeline_cleanup/plan.md` — full design + bug-to-fix mapping
52
+ - `requirements/08_release_pipeline.md` updated to reflect the new flow
53
+ - `requirements/tasks.md` — T-PC-001 added under Phase 14
54
+
3
55
  ## 0.5.1
4
56
 
5
57
  ### Fixed
@@ -12,8 +12,9 @@ module PlanMyStuff
12
12
  config = PlanMyStuff.configuration
13
13
  message_type = request.headers['x-amz-sns-message-type'].to_s
14
14
 
15
+ Rails.logger.info("[PlanMyStuff] SNS #{message_type}: #{sns_params.to_unsafe_h.inspect}")
16
+
15
17
  unless message_type == 'Notification'
16
- Rails.logger.info("[PlanMyStuff] SNS #{message_type}: #{sns_params.to_unsafe_h.inspect}")
17
18
  head(:ok)
18
19
 
19
20
  return
@@ -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 %>
@@ -93,8 +93,12 @@ PMS.configure do |config|
93
93
  # --------------------------------------------------------------------------
94
94
  # URL prefix
95
95
  # --------------------------------------------------------------------------
96
- # Prefix for building user-facing ticket URLs in your app.
97
- # config.issues_url_prefix = 'https://myapp.example.com/tickets/'
96
+ # Prefix for building user-facing ticket URLs in your app. The issue
97
+ # number is appended to form `Issue#user_link`, which the gem also
98
+ # writes as the visible body of the GitHub issue.
99
+ # Recommended default:
100
+ # config.issues_url_prefix =
101
+ # "#{Rails.application.config.action_mailer.default_url_options[:host]}/issues"
98
102
 
99
103
  # --------------------------------------------------------------------------
100
104
  # Request gateway
@@ -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
@@ -82,6 +82,17 @@ module PlanMyStuff
82
82
  number = read_field(result, :number)
83
83
  store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
84
84
 
85
+ link_body = visible_body_for(number, resolved_repo)
86
+ if link_body.present?
87
+ result = client.rest(
88
+ :update_issue,
89
+ resolved_repo,
90
+ number,
91
+ body: MetadataParser.serialize(issue_metadata.to_h, link_body),
92
+ )
93
+ store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
94
+ end
95
+
85
96
  issue = find(number, repo: resolved_repo)
86
97
 
87
98
  if add_to_project.present?
@@ -147,7 +158,7 @@ module PlanMyStuff
147
158
  case metadata
148
159
  when PlanMyStuff::IssueMetadata
149
160
  metadata.validate_custom_fields!
150
- options[:body] = MetadataParser.serialize(metadata.to_h, '')
161
+ options[:body] = MetadataParser.serialize(metadata.to_h, visible_body_for(number, resolved_repo))
151
162
  when Hash
152
163
  current = client.rest(:issue, resolved_repo, number)
153
164
  current_body = current.respond_to?(:body) ? current.body : current[:body]
@@ -162,7 +173,7 @@ module PlanMyStuff
162
173
  merged_custom_fields,
163
174
  ).validate!
164
175
 
165
- options[:body] = MetadataParser.serialize(existing_metadata, '')
176
+ options[:body] = MetadataParser.serialize(existing_metadata, visible_body_for(number, resolved_repo))
166
177
  end
167
178
 
168
179
  update_body_comment(number, resolved_repo, body) if body
@@ -235,6 +246,26 @@ module PlanMyStuff
235
246
 
236
247
  private
237
248
 
249
+ # Builds the visible body string written to GitHub for an issue:
250
+ # a markdown link to the consuming-app per-issue URL (carrying
251
+ # the repo as a +?repo=+ query param so the consuming app knows
252
+ # which repo this issue lives in), labelled with the GitHub
253
+ # +Org/Repo#number+. Returns +""+ when either
254
+ # +config.issues_url_prefix+ or +number+ is missing.
255
+ #
256
+ # @param number [Integer]
257
+ # @param repo [String] full repo path, e.g. +"BrandsInsurance/Element"+
258
+ #
259
+ # @return [String]
260
+ #
261
+ def visible_body_for(number, repo)
262
+ prefix = PlanMyStuff.configuration.issues_url_prefix
263
+ return '' if prefix.blank? || number.blank?
264
+
265
+ url = "#{prefix.to_s.chomp('/')}/#{number}?repo=#{URI.encode_www_form_component(repo)}"
266
+ "[#{repo}##{number}](#{url})"
267
+ end
268
+
238
269
  # Hydrates an Issue from a GitHub API response.
239
270
  #
240
271
  # @param github_issue [Object] Octokit issue response
@@ -298,6 +329,20 @@ module PlanMyStuff
298
329
  @body_dirty = true
299
330
  end
300
331
 
332
+ # @return [String, nil] per-issue URL in the consuming app
333
+ # (+config.issues_url_prefix+ + +"/"+ + +number+ + +"?repo=Org/Repo"+,
334
+ # or +nil+ when either prefix or number is missing). Also rendered
335
+ # as the destination of the markdown link in the GitHub issue body.
336
+ def user_link
337
+ prefix = PlanMyStuff.configuration.issues_url_prefix
338
+ return if prefix.blank? || number.blank?
339
+
340
+ base = "#{prefix.to_s.chomp('/')}/#{number}"
341
+ return base if repo.blank?
342
+
343
+ "#{base}?repo=#{URI.encode_www_form_component(repo.full_name)}"
344
+ end
345
+
301
346
  # @return [Array<PlanMyStuff::Approval>] all required approvers (pending + approved)
302
347
  def approvers
303
348
  metadata.approvals
@@ -689,6 +734,14 @@ module PlanMyStuff
689
734
  safe_read_field(github_response, :html_url)
690
735
  end
691
736
 
737
+ # GitHub assignees for this issue, by login.
738
+ #
739
+ # @return [Array<String>]
740
+ #
741
+ def assignees
742
+ extract_assignee_logins(github_response)
743
+ end
744
+
692
745
  # @return [Boolean]
693
746
  def pms_issue?
694
747
  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,8 +3,8 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 5
7
- TINY = 1
6
+ MINOR = 7
7
+ TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
10
10
  PRE = nil
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.1
4
+ version: 0.7.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