Module: PlanMyStuff::Pipeline

Defined in:
lib/plan_my_stuff/pipeline.rb,
lib/plan_my_stuff/pipeline/status.rb,
lib/plan_my_stuff/pipeline/issue_linker.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: IssueLinker, Status

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



282
283
284
285
286
287
288
289
290
291
# File 'lib/plan_my_stuff/pipeline.rb', line 282

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 (submit!, 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:



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

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



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

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)


164
165
166
167
168
169
170
171
172
173
# File 'lib/plan_my_stuff/pipeline.rb', line 164

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

  early_statuses = [
    resolve_status_name(PlanMyStuff::Pipeline::Status::SUBMITTED),
    resolve_status_name(PlanMyStuff::Pipeline::Status::STARTED),
  ]

  early_statuses.exclude?(prior_status)
end

.mark_in_review!(project_item) ⇒ Hash

Moves a project item to “In Review”.

Parameters:

Returns:

  • (Hash)

    mutation result



196
197
198
199
200
201
202
203
# File 'lib/plan_my_stuff/pipeline.rb', line 196

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



226
227
228
229
230
231
232
233
# File 'lib/plan_my_stuff/pipeline.rb', line 226

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

.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 “Submitted” or “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



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/plan_my_stuff/pipeline.rb', line 137

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

Moves a project item to “Testing”.

Parameters:

Returns:

  • (Hash)

    mutation result



211
212
213
214
215
216
217
218
# File 'lib/plan_my_stuff/pipeline.rb', line 211

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

  instrument(PlanMyStuff::Pipeline::Status::TESTING, project_item)
  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



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

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)


56
57
58
# File 'lib/plan_my_stuff/pipeline.rb', line 56

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:



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/plan_my_stuff/pipeline.rb', line 250

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

.submit!(issue, assignee:, project_number: nil) ⇒ PlanMyStuff::ProjectItem

Adds an issue to the pipeline project, moves it to “Submitted”, and assigns the given developer.

Called when an issue is assigned to a dev, NOT on issue creation. Un-triaged issues should not be in the pipeline.

Parameters:

  • issue (PlanMyStuff::Issue)
  • assignee (String)

    GitHub username

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

Returns:



110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/plan_my_stuff/pipeline.rb', line 110

def submit!(issue, assignee:, project_number: nil)
  guard_approvals!(issue)
  number = resolve_pipeline_project_number(project_number)
  project_item = PlanMyStuff::ProjectItem.create!(issue, project_number: number)

  status = resolve_status_name(PlanMyStuff::Pipeline::Status::SUBMITTED)
  project_item.move_to!(status)
  project_item.assign!(assignee)

  instrument(PlanMyStuff::Pipeline::Status::SUBMITTED, project_item)
  project_item
end

.take!(project_item) ⇒ Hash

Moves a project item to “Started”.

Parameters:

Returns:

  • (Hash)

    mutation result



181
182
183
184
185
186
187
188
# File 'lib/plan_my_stuff/pipeline.rb', line 181

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