Module: PlanMyStuff::Pipeline

Defined in:
lib/plan_my_stuff/pipeline.rb,
lib/plan_my_stuff/pipeline/status.rb,
lib/plan_my_stuff/pipeline/testing.rb,
lib/plan_my_stuff/pipeline/issue_linker.rb,
lib/plan_my_stuff/pipeline/completed_sweep.rb

Overview

High-level orchestration layer for the release pipeline.

Named lifecycle methods wrap the low-level ProjectItem.move_to! calls, resolve configurable status aliases, and fire ActiveSupport::Notifications events on every transition.

No transition enforcement – any status can move to any other.

Defined Under Namespace

Modules: CompletedSweep, IssueLinker, Status, Testing

Class Method Summary collapse

Class Method Details

.complete_deployment!(project_item, deployment_id: nil) ⇒ Hash?

Moves a project item to “Completed” if the linked issue has auto_complete enabled. Returns nil when auto-complete is off (item stays at “Release in Progress”).

Parameters:

Returns:

  • (Hash, nil)

    mutation result or nil



287
288
289
290
291
292
293
294
295
296
# File 'lib/plan_my_stuff/pipeline.rb', line 287

def complete_deployment!(project_item, deployment_id: nil)
  issue = project_item.issue
  return unless issue..auto_complete?

  status = resolve_status_name(PlanMyStuff::Pipeline::Status::COMPLETED)
  result = project_item.move_to!(status)

  instrument(PlanMyStuff::Pipeline::Status::COMPLETED, project_item, deployment_id: deployment_id)
  result
end

.guard_approvals!(issue) ⇒ void

This method returns an undefined value.

Raises PlanMyStuff::PendingApprovalsError when issue has pending manager approvals. No-op for nil issues or issues that either have no approvers required or are fully approved.

Called at the top of every forward transition (take!, mark_in_review!, request_testing!, mark_ready_for_release!). Batch / CI-driven transitions (start_deployment!, complete_deployment!) and reverse transitions (remove!) are intentionally NOT gated – earlier forward transitions already required approval, and gating batch/automated paths would abort entire deploys on a single approval revoke.

Parameters:

Raises:



38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/plan_my_stuff/pipeline.rb', line 38

def guard_approvals!(issue)
  return if issue.nil?

  return unless issue.approvals_required?

  return if issue.fully_approved?

  raise(PlanMyStuff::PendingApprovalsError.new(
    issue: issue,
    pending_count: issue.pending_approvals.count,
  ))
end

.instrument(status, project_item, **extra) ⇒ void

This method returns an undefined value.

Fires an ActiveSupport::Notifications event for a pipeline transition.

Parameters:

  • status (String)

    canonical status name (used for event key)

  • project_item (PlanMyStuff::ProjectItem)
  • extra (Hash)

    additional payload entries



70
71
72
73
74
75
76
77
78
79
80
# File 'lib/plan_my_stuff/pipeline.rb', line 70

def instrument(status, project_item, **extra)
  key = PlanMyStuff::Pipeline::Status.key_for(status)
  payload = {
    project_item: project_item,
    issue_number: project_item.number,
    status: status,
    timestamp: Time.current,
  }.merge(extra)

  ActiveSupport::Notifications.instrument("plan_my_stuff.pipeline.#{key}", payload)
end

.late_removal?(prior_status) ⇒ Boolean

Returns true when prior_status is past “Started” in the pipeline. Honors configured status aliases. nil is treated as not-late.

Parameters:

  • prior_status (String, nil)

Returns:

  • (Boolean)


140
141
142
143
144
145
# File 'lib/plan_my_stuff/pipeline.rb', line 140

def late_removal?(prior_status)
  return false if prior_status.nil?

  started = resolve_status_name(PlanMyStuff::Pipeline::Status::STARTED)
  prior_status != started
end

.mark_in_review!(project_item) ⇒ Hash

Moves a project item to “In Review”.

Parameters:

Returns:

  • (Hash)

    mutation result



190
191
192
193
194
195
196
197
# File 'lib/plan_my_stuff/pipeline.rb', line 190

def mark_in_review!(project_item)
  guard_approvals!(project_item&.issue)
  status = resolve_status_name(PlanMyStuff::Pipeline::Status::IN_REVIEW)
  result = project_item.move_to!(status)

  instrument(PlanMyStuff::Pipeline::Status::IN_REVIEW, project_item)
  result
end

.mark_ready_for_release!(project_item) ⇒ Hash

Moves a project item to “Ready for Release”.

Parameters:

Returns:

  • (Hash)

    mutation result



231
232
233
234
235
236
237
238
# File 'lib/plan_my_stuff/pipeline.rb', line 231

def mark_ready_for_release!(project_item)
  guard_approvals!(project_item&.issue)
  status = resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE)
  result = project_item.move_to!(status)

  instrument(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE, project_item)
  result
end

.release_cycle_locked?(project_item) ⇒ Boolean

Returns true when project_item is at a release-cycle status (“Ready for Release”, “Release in Progress”, or “Completed”). Honors configured status aliases.

Used to lock items against webhook-driven removals once they enter the release path – a stray unassign should not yank an item out of “Release in Progress”.

Parameters:

Returns:

  • (Boolean)


159
160
161
162
163
164
165
166
167
# File 'lib/plan_my_stuff/pipeline.rb', line 159

def release_cycle_locked?(project_item)
  locked_statuses = [
    resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE),
    resolve_status_name(PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS),
    resolve_status_name(PlanMyStuff::Pipeline::Status::COMPLETED),
  ]

  locked_statuses.include?(project_item.status)
end

.remove!(project_item) ⇒ String?

Removes a project item from the pipeline project entirely. Captures the prior status before deletion so subscribers can decide whether the removal happened late in the lifecycle.

Always fires plan_my_stuff.pipeline.removed. Additionally fires plan_my_stuff.pipeline.removed_late when prior_status is past “Started” (i.e. anything other than “Started”, accounting for configured status aliases). nil status is treated as not-late.

Parameters:

Returns:

  • (String, nil)

    the deleted item ID, or nil if already destroyed



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/plan_my_stuff/pipeline.rb', line 113

def remove!(project_item)
  prior_status = project_item.status
  result = project_item.destroy!

  payload = {
    project_item: project_item,
    issue_number: project_item.number,
    prior_status: prior_status,
    timestamp: Time.current,
  }

  ActiveSupport::Notifications.instrument('plan_my_stuff.pipeline.removed', payload)

  if late_removal?(prior_status)
    ActiveSupport::Notifications.instrument('plan_my_stuff.pipeline.removed_late', payload)
  end

  result
end

.request_testing!(project_item) ⇒ Hash

Marks a project item as in testing by setting the Testing custom single-select field to its active value. Does NOT change the item’s Status – testing runs orthogonally to In Review.

Parameters:

Returns:

  • (Hash)

    mutation result



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/plan_my_stuff/pipeline.rb', line 207

def request_testing!(project_item)
  guard_approvals!(project_item&.issue)
  field_name = PlanMyStuff.configuration.pipeline_testing_field_name
  value = PlanMyStuff.configuration.pipeline_testing_values.fetch(:active)
  result = project_item.update_single_select_field!(field_name, value)

  ActiveSupport::Notifications.instrument(
    'plan_my_stuff.pipeline.testing',
    project_item: project_item,
    issue_number: project_item.number,
    field_name: field_name,
    value: value,
    timestamp: Time.current,
  )

  result
end

.resolve_pipeline_project_number(project_number = nil) ⇒ Integer

Returns the pipeline project number from the explicit argument, pipeline_project_number, or default_project_number.

Parameters:

  • project_number (Integer, nil) (defaults to: nil)

Returns:

  • (Integer)

Raises:

  • (ArgumentError)

    if no project number can be resolved



91
92
93
94
95
96
97
98
# File 'lib/plan_my_stuff/pipeline.rb', line 91

def resolve_pipeline_project_number(project_number = nil)
  config = PlanMyStuff.configuration
  result = project_number || config.pipeline_project_number || config.default_project_number

  raise(ArgumentError, 'No pipeline project number configured') if result.nil?

  result
end

.resolve_status_name(canonical) ⇒ String

Resolves a canonical status name to its configured display alias, falling back to the canonical name when no alias is configured.

Parameters:

  • canonical (String)

    one of the Pipeline::Status constants

Returns:

  • (String)


58
59
60
# File 'lib/plan_my_stuff/pipeline.rb', line 58

def resolve_status_name(canonical)
  PlanMyStuff.configuration.pipeline_statuses.fetch(canonical, canonical)
end

.start_deployment!(project_number: nil, commit_sha: nil) ⇒ Array<PlanMyStuff::ProjectItem>

Finds ALL items at “Ready for Release” in the pipeline project and moves each to “Release in Progress”. Fires an event per item.

When commit_sha is given (the merge_commit_sha of the PR into production), it is stamped onto every moved item’s linked issue metadata. AwsController#handle_deployment_completed later matches this sha against the configured production_commit_sha to decide which items to auto-complete, so this is the only commit sha in the release cycle that matters.

Parameters:

  • project_number (Integer, nil) (defaults to: nil)
  • commit_sha (String, nil) (defaults to: nil)

Returns:



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/plan_my_stuff/pipeline.rb', line 255

def start_deployment!(project_number: nil, commit_sha: nil)
  number = resolve_pipeline_project_number(project_number)
  project = PlanMyStuff::Project.find(number)

  ready_status = resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE)
  in_progress_status = resolve_status_name(PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS)

  items = project.items.select { |item| item.status == ready_status }

  items.each do |item|
    if commit_sha.present?
      issue = item.issue
      issue..commit_sha = commit_sha
      issue.save!
    end

    item.move_to!(in_progress_status)
    instrument(PlanMyStuff::Pipeline::Status::RELEASE_IN_PROGRESS, item, commit_sha: commit_sha)
  end

  items
end

.take!(project_item) ⇒ Hash

Moves a project item to “Started”.

Parameters:

Returns:

  • (Hash)

    mutation result



175
176
177
178
179
180
181
182
# File 'lib/plan_my_stuff/pipeline.rb', line 175

def take!(project_item)
  guard_approvals!(project_item&.issue)
  status = resolve_status_name(PlanMyStuff::Pipeline::Status::STARTED)
  result = project_item.move_to!(status)

  instrument(PlanMyStuff::Pipeline::Status::STARTED, project_item)
  result
end