plan_my_stuff 0.6.0 → 0.8.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/README.md +100 -103
  4. data/app/controllers/plan_my_stuff/application_controller.rb +22 -3
  5. data/app/controllers/plan_my_stuff/comments_controller.rb +14 -16
  6. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +23 -13
  7. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +7 -5
  8. data/app/controllers/plan_my_stuff/issues/links_controller.rb +14 -18
  9. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +99 -28
  10. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +13 -5
  11. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +7 -5
  12. data/app/controllers/plan_my_stuff/issues_controller.rb +24 -28
  13. data/app/controllers/plan_my_stuff/labels_controller.rb +21 -5
  14. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +13 -6
  15. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +5 -4
  16. data/app/controllers/plan_my_stuff/project_items_controller.rb +30 -5
  17. data/app/controllers/plan_my_stuff/projects_controller.rb +16 -16
  18. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +21 -11
  19. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +9 -4
  20. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +30 -14
  21. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +50 -17
  22. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +32 -49
  23. data/app/jobs/plan_my_stuff/application_job.rb +2 -3
  24. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +15 -22
  25. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  26. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +1 -0
  27. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  28. data/app/views/plan_my_stuff/issues/index.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +23 -2
  31. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +1 -0
  32. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -1
  33. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +50 -7
  34. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -1
  35. data/app/views/plan_my_stuff/issues/show.html.erb +5 -2
  36. data/app/views/plan_my_stuff/partials/_flash.html.erb +4 -0
  37. data/app/views/plan_my_stuff/projects/edit.html.erb +1 -3
  38. data/app/views/plan_my_stuff/projects/index.html.erb +1 -1
  39. data/app/views/plan_my_stuff/projects/new.html.erb +1 -3
  40. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +1 -0
  41. data/app/views/plan_my_stuff/projects/show.html.erb +13 -3
  42. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +1 -3
  43. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +1 -3
  44. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +1 -3
  45. data/app/views/plan_my_stuff/testing_projects/new.html.erb +1 -3
  46. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +4 -3
  47. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +1 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +1 -0
  49. data/app/views/plan_my_stuff/testing_projects/show.html.erb +2 -2
  50. data/config/routes.rb +2 -2
  51. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +56 -3
  52. data/lib/plan_my_stuff/approval.rb +12 -4
  53. data/lib/plan_my_stuff/aws_sns_simulator.rb +12 -6
  54. data/lib/plan_my_stuff/base_metadata.rb +4 -15
  55. data/lib/plan_my_stuff/base_project.rb +68 -55
  56. data/lib/plan_my_stuff/base_project_item.rb +61 -57
  57. data/lib/plan_my_stuff/base_project_metadata.rb +1 -1
  58. data/lib/plan_my_stuff/client.rb +136 -48
  59. data/lib/plan_my_stuff/comment.rb +57 -57
  60. data/lib/plan_my_stuff/comment_metadata.rb +1 -1
  61. data/lib/plan_my_stuff/configuration.rb +95 -82
  62. data/lib/plan_my_stuff/errors.rb +10 -10
  63. data/lib/plan_my_stuff/graphql/queries.rb +1 -1
  64. data/lib/plan_my_stuff/issue.rb +501 -322
  65. data/lib/plan_my_stuff/issue_metadata.rb +10 -10
  66. data/lib/plan_my_stuff/label.rb +32 -16
  67. data/lib/plan_my_stuff/link.rb +15 -15
  68. data/lib/plan_my_stuff/markdown.rb +12 -6
  69. data/lib/plan_my_stuff/metadata_parser.rb +3 -1
  70. data/lib/plan_my_stuff/notifications.rb +1 -1
  71. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +2 -2
  72. data/lib/plan_my_stuff/pipeline/issue_linker.rb +1 -1
  73. data/lib/plan_my_stuff/pipeline.rb +61 -83
  74. data/lib/plan_my_stuff/project.rb +4 -4
  75. data/lib/plan_my_stuff/project_item_metadata.rb +1 -1
  76. data/lib/plan_my_stuff/project_metadata.rb +1 -1
  77. data/lib/plan_my_stuff/reminders/closer.rb +1 -1
  78. data/lib/plan_my_stuff/reminders/fire.rb +3 -3
  79. data/lib/plan_my_stuff/reminders/sweep.rb +4 -4
  80. data/lib/plan_my_stuff/repo.rb +12 -6
  81. data/lib/plan_my_stuff/test_helpers.rb +11 -11
  82. data/lib/plan_my_stuff/testing_project.rb +12 -11
  83. data/lib/plan_my_stuff/testing_project_item.rb +11 -9
  84. data/lib/plan_my_stuff/testing_project_metadata.rb +2 -2
  85. data/lib/plan_my_stuff/version.rb +1 -1
  86. data/lib/plan_my_stuff/webhook_replayer.rb +14 -2
  87. data/lib/plan_my_stuff.rb +26 -2
  88. data/lib/tasks/plan_my_stuff.rake +33 -20
  89. metadata +3 -2
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- class IssueMetadata < BaseMetadata
4
+ class IssueMetadata < PlanMyStuff::BaseMetadata
5
5
  # @return [Time, nil] first support action timestamp, nil until set
6
6
  attr_accessor :responded_at
7
7
  # @return [String, nil] user-facing URL in the consuming app
@@ -123,7 +123,7 @@ module PlanMyStuff
123
123
  #
124
124
  def normalize_links(raw)
125
125
  Array.wrap(raw).filter_map do |entry|
126
- PlanMyStuff::Link.build(entry)
126
+ PlanMyStuff::Link.build!(entry)
127
127
  rescue ActiveModel::ValidationError, ArgumentError
128
128
  next
129
129
  end
@@ -209,16 +209,16 @@ module PlanMyStuff
209
209
  def visible_to?(user)
210
210
  return true if public?
211
211
 
212
- resolved = UserResolver.resolve(user)
213
- return true if UserResolver.support?(resolved)
212
+ resolved = PlanMyStuff::UserResolver.resolve(user)
213
+ return true if PlanMyStuff::UserResolver.support?(resolved)
214
214
 
215
- visibility_allowlist.include?(UserResolver.user_id(resolved))
215
+ visibility_allowlist.include?(PlanMyStuff::UserResolver.user_id(resolved))
216
216
  end
217
217
 
218
218
  # @return [Hash]
219
219
  def to_h
220
220
  super.merge(
221
- responded_at: format_time(responded_at),
221
+ responded_at: PlanMyStuff.format_time(responded_at),
222
222
  issues_url: issues_url,
223
223
  priority_list: priority_list,
224
224
  priority_list_priority: priority_list_priority,
@@ -227,12 +227,12 @@ module PlanMyStuff
227
227
  auto_complete: auto_complete,
228
228
  links: links.map(&:to_h),
229
229
  approvals: approvals.map(&:to_h),
230
- waiting_on_user_at: format_time(waiting_on_user_at),
231
- waiting_on_approval_at: format_time(waiting_on_approval_at),
232
- next_reminder_at: format_time(next_reminder_at),
230
+ waiting_on_user_at: PlanMyStuff.format_time(waiting_on_user_at),
231
+ waiting_on_approval_at: PlanMyStuff.format_time(waiting_on_approval_at),
232
+ next_reminder_at: PlanMyStuff.format_time(next_reminder_at),
233
233
  reminder_days: reminder_days,
234
234
  closed_by_inactivity: closed_by_inactivity,
235
- archived_at: format_time(archived_at),
235
+ archived_at: PlanMyStuff.format_time(archived_at),
236
236
  )
237
237
  end
238
238
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- # Wraps a GitHub label with a reference to its parent issue.
5
- # Class methods provide the public API for add/remove operations.
4
+ # Wraps a GitHub label with a reference to its parent issue. Class methods provide the public API for add/remove
5
+ # operations.
6
6
  class Label < PlanMyStuff::ApplicationRecord
7
7
  # @return [String, nil]
8
8
  attribute :name, :string
@@ -18,14 +18,14 @@ module PlanMyStuff
18
18
  #
19
19
  # @return [Array<PlanMyStuff::Label>]
20
20
  #
21
- def add(issue:, labels:, user: nil)
21
+ def add!(issue:, labels:, user: nil)
22
22
  label_names = Array.wrap(labels)
23
23
 
24
24
  result = PlanMyStuff.client.rest(
25
25
  :add_labels_to_an_issue, issue.repo, issue.number, label_names,
26
26
  )
27
27
 
28
- PMS::Cache.delete_issue(issue.repo, issue.number)
28
+ PlanMyStuff::Cache.delete_issue(issue.repo, issue.number)
29
29
 
30
30
  PlanMyStuff::Notifications.instrument(
31
31
  'label.added', issue, user: user, labels: label_names,
@@ -34,9 +34,8 @@ module PlanMyStuff
34
34
  result.map { |gh_label| build(gh_label, issue: issue) }
35
35
  end
36
36
 
37
- # Ensures a label exists on the given repo, creating it if missing.
38
- # Idempotent: a 404 from +label+ triggers creation; a 422 from
39
- # +add_label+ (concurrent-creation race) is treated as success.
37
+ # Ensures a label exists on the given repo, creating it if missing. Idempotent: a 404 from +label+ triggers
38
+ # creation; a 422 from +add_label+ (concurrent-creation race) is treated as success.
40
39
  #
41
40
  # @param repo [String, Symbol] repo name or key
42
41
  # @param name [String] label name
@@ -51,7 +50,25 @@ module PlanMyStuff
51
50
  rescue PlanMyStuff::APIError => e
52
51
  raise unless e.status == 404
53
52
 
54
- create_label(client, repo, name, color, description)
53
+ create_label!(client, repo, name, color, description)
54
+ end
55
+
56
+ # Returns whether a label exists in the repo.
57
+ #
58
+ # @raise [PlanMyStuff::APIError] if the GET fails with any status other than 404
59
+ #
60
+ # @param repo [String, Symbol, PlanMyStuff::Repo] repo name or key
61
+ # @param name [String] label name
62
+ #
63
+ # @return [Boolean]
64
+ #
65
+ def exists?(repo:, name:)
66
+ PlanMyStuff.client.rest(:label, repo, name)
67
+ true
68
+ rescue PlanMyStuff::APIError => e
69
+ raise unless e.status == 404
70
+
71
+ false
55
72
  end
56
73
 
57
74
  # Removes labels from a GitHub issue.
@@ -62,7 +79,7 @@ module PlanMyStuff
62
79
  #
63
80
  # @return [Array<Array<PlanMyStuff::Label>>] results of each removal
64
81
  #
65
- def remove(issue:, labels:, user: nil)
82
+ def remove!(issue:, labels:, user: nil)
66
83
  label_names = Array.wrap(labels)
67
84
 
68
85
  results = label_names.map do |label|
@@ -70,7 +87,7 @@ module PlanMyStuff
70
87
  result.map { |gh_label| build(gh_label, issue: issue) }
71
88
  end
72
89
 
73
- PMS::Cache.delete_issue(issue.repo, issue.number)
90
+ PlanMyStuff::Cache.delete_issue(issue.repo, issue.number)
74
91
 
75
92
  PlanMyStuff::Notifications.instrument(
76
93
  'label.removed', issue, user: user, labels: label_names,
@@ -81,9 +98,8 @@ module PlanMyStuff
81
98
 
82
99
  private
83
100
 
84
- # Creates a label, tolerating the 422 "already exists" race that
85
- # occurs when a concurrent ensure! slipped in between our 404 read
86
- # and this write.
101
+ # Creates a label, tolerating the 422 "already exists" race that occurs when a concurrent ensure! slipped in
102
+ # between our 404 read and this write.
87
103
  #
88
104
  # @param client [PlanMyStuff::Client]
89
105
  # @param repo [String, Symbol]
@@ -93,7 +109,7 @@ module PlanMyStuff
93
109
  #
94
110
  # @return [void]
95
111
  #
96
- def create_label(client, repo, name, color, description)
112
+ def create_label!(client, repo, name, color, description)
97
113
  options = {}
98
114
  options[:description] = description if description
99
115
 
@@ -117,8 +133,8 @@ module PlanMyStuff
117
133
  end
118
134
  end
119
135
 
120
- # Serializes the label to a JSON-safe hash, excluding the back-reference
121
- # to the parent issue to prevent recursive serialization cycles.
136
+ # Serializes the label to a JSON-safe hash, excluding the back-reference to the parent issue to prevent
137
+ # recursive serialization cycles.
122
138
  #
123
139
  # @return [Hash]
124
140
  #
@@ -3,14 +3,12 @@
3
3
  require 'active_model'
4
4
 
5
5
  module PlanMyStuff
6
- # Value object representing a typed relationship between two issues.
7
- # Built by +Issue#add_related!+, +#add_blocker!+, +#add_sub_issue!+,
8
- # +#set_parent!+, and +#mark_duplicate!+; also returned from their
9
- # +remove_*+ counterparts so callers can render activity-log lines
10
- # like +"Added parent issue: owner/repo#42"+.
6
+ # Value object representing a typed relationship between two issues. Built by +Issue#add_related!+,
7
+ # +#add_blocker!+, +#add_sub_issue!+, +#set_parent!+, and +#mark_duplicate!+; also returned from their +remove_*+
8
+ # counterparts so callers can render activity-log lines like +"Added parent issue: owner/repo#42"+.
11
9
  #
12
- # Metadata-backed types live in +IssueMetadata#links+; native types
13
- # are routed through GitHub APIs and never persisted in our metadata.
10
+ # Metadata-backed types live in +IssueMetadata#links+; native types are routed through GitHub APIs and never
11
+ # persisted in our metadata.
14
12
  class Link
15
13
  METADATA_TYPES = %w[related].freeze
16
14
  NATIVE_TYPES = %w[blocking blocked_by parent sub_ticket duplicate_of].freeze
@@ -32,11 +30,10 @@ module PlanMyStuff
32
30
  validates :repo, presence: true
33
31
 
34
32
  class << self
35
- # Builds and validates a +Link+ from an +Issue+-like object, another
36
- # +Link+, or a hash. +type:+ fills in when the input does not carry
37
- # one; +source_repo+ fills in when the hash input does not carry
38
- # one. Raises +ActiveModel::ValidationError+ on missing/invalid
39
- # fields, +ArgumentError+ when +input+ is an unsupported shape.
33
+ # Builds and validates a +Link+ from an +Issue+-like object, another +Link+, or a hash. +type:+ fills in when
34
+ # the input does not carry one; +source_repo+ fills in when the hash input does not carry one. Raises
35
+ # +ActiveModel::ValidationError+ on missing/invalid fields, +ArgumentError+ when +input+ is an unsupported
36
+ # shape.
40
37
  #
41
38
  # @param input [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
42
39
  # @param type [String, Symbol, nil]
@@ -44,14 +41,14 @@ module PlanMyStuff
44
41
  #
45
42
  # @return [PlanMyStuff::Link]
46
43
  #
47
- def build(input, type: nil, source_repo: nil)
44
+ def build!(input, type: nil, source_repo: nil)
48
45
  return input if input.is_a?(PlanMyStuff::Link)
49
46
 
50
47
  link =
51
48
  if input.is_a?(Hash)
52
49
  build_from_hash(input, type: type, source_repo: source_repo)
53
50
  else
54
- build_from_issue_like(input, type: type)
51
+ build_from_issue_like!(input, type: type)
55
52
  end
56
53
 
57
54
  link.validate!
@@ -60,8 +57,11 @@ module PlanMyStuff
60
57
 
61
58
  private
62
59
 
60
+ # @raise [ArgumentError] if input does not respond to #number and #repo
61
+ #
63
62
  # @return [PlanMyStuff::Link]
64
- def build_from_issue_like(input, type:)
63
+ #
64
+ def build_from_issue_like!(input, type:)
65
65
  if !input.respond_to?(:number) || !input.respond_to?(:repo)
66
66
  raise(ArgumentError, "Cannot build Link from #{input.class}")
67
67
  end
@@ -5,8 +5,10 @@ require 'active_support/core_ext/hash/deep_merge'
5
5
  module PlanMyStuff
6
6
  module Markdown
7
7
  class << self
8
- # Renders markdown text to HTML using the configured renderer.
9
- # Per-call options are deep-merged on top of config.markdown_options.
8
+ # Renders markdown text to HTML using the configured renderer. Per-call options are deep-merged on top of
9
+ # config.markdown_options.
10
+ #
11
+ # @raise [ArgumentError] if the configured renderer is not an option
10
12
  #
11
13
  # @param text [String] raw markdown text
12
14
  # @param options [Hash] renderer-specific options (merged over config defaults)
@@ -22,9 +24,9 @@ module PlanMyStuff
22
24
 
23
25
  case config.markdown_renderer
24
26
  when :commonmarker
25
- render_commonmarker(text, merged)
27
+ render_commonmarker!(text, merged)
26
28
  when :redcarpet
27
- render_redcarpet(text, merged)
29
+ render_redcarpet!(text, merged)
28
30
  when nil
29
31
  "<code>#{ERB::Util.html_escape(text)}</code>"
30
32
  else
@@ -37,12 +39,14 @@ module PlanMyStuff
37
39
 
38
40
  private
39
41
 
42
+ # @raise [PlanMyStuff::Error] if commonmarker gem is not available
43
+ #
40
44
  # @param text [String]
41
45
  # @param options [Hash]
42
46
  #
43
47
  # @return [String]
44
48
  #
45
- def render_commonmarker(text, options)
49
+ def render_commonmarker!(text, options)
46
50
  require('commonmarker') unless defined?(Commonmarker)
47
51
 
48
52
  if options.empty?
@@ -58,12 +62,14 @@ module PlanMyStuff
58
62
  )
59
63
  end
60
64
 
65
+ # @raise [PlanMyStuff::Error] if redcarpet gem is not available
66
+ #
61
67
  # @param text [String]
62
68
  # @param options [Hash]
63
69
  #
64
70
  # @return [String]
65
71
  #
66
- def render_redcarpet(text, options)
72
+ def render_redcarpet!(text, options)
67
73
  require('redcarpet') unless defined?(Redcarpet)
68
74
 
69
75
  options = options.dup
@@ -30,12 +30,14 @@ module PlanMyStuff
30
30
 
31
31
  # Serializes a metadata hash and body into the stored format
32
32
  #
33
+ # @raise [ArgumentError] if metadata is not a Hash or PlanMyStuff::CustomFields
34
+ #
33
35
  # @param metadata [Hash, PlanMyStuff::CustomFields]
34
36
  # @param body [String]
35
37
  #
36
38
  # @return [String]
37
39
  #
38
- def serialize(metadata, body)
40
+ def serialize!(metadata, body)
39
41
  if !metadata.is_a?(Hash) && !metadata.is_a?(PlanMyStuff::CustomFields)
40
42
  raise(ArgumentError, "metadata must be a Hash or PlanMyStuff::CustomFields, got #{metadata.class}")
41
43
  end
@@ -113,7 +113,7 @@ module PlanMyStuff
113
113
  logger = rails_logger
114
114
  return if logger.nil?
115
115
 
116
- logger.debug { "[PMS] #{EVENT_PREFIX}.#{event} #{log_fields(payload)}" }
116
+ logger.debug { "[PlanMyStuff] #{EVENT_PREFIX}.#{event} #{log_fields(payload)}" }
117
117
  end
118
118
 
119
119
  # @return [Logger, nil]
@@ -20,11 +20,11 @@ module PlanMyStuff
20
20
  #
21
21
  # @return [Array<PlanMyStuff::ProjectItem>] removed items
22
22
  #
23
- def perform(project_number: nil, now: Time.current)
23
+ def perform!(project_number: nil, now: Time.current)
24
24
  config = PlanMyStuff.configuration
25
25
  return [] unless config.pipeline_completion_purge_enabled
26
26
 
27
- number = PlanMyStuff::Pipeline.resolve_pipeline_project_number(project_number)
27
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!(project_number)
28
28
  project = PlanMyStuff::Project.find(number)
29
29
 
30
30
  completed_status = PlanMyStuff::Pipeline.resolve_status_name(
@@ -52,7 +52,7 @@ module PlanMyStuff
52
52
  # @return [PlanMyStuff::ProjectItem, nil] matching item, or nil if not found
53
53
  #
54
54
  def find_project_item(issue_number, _repo: nil, project_number: nil)
55
- number = PlanMyStuff::Pipeline.resolve_pipeline_project_number(project_number)
55
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!(project_number)
56
56
  project = PlanMyStuff::Project.find(number)
57
57
 
58
58
  project.items.find { |item| item.number == issue_number }
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/notifications'
4
3
  require_relative 'pipeline/completed_sweep'
5
4
  require_relative 'pipeline/issue_linker'
6
5
  require_relative 'pipeline/status'
@@ -9,27 +8,23 @@ require_relative 'pipeline/testing'
9
8
  module PlanMyStuff
10
9
  # High-level orchestration layer for the release pipeline.
11
10
  #
12
- # Named lifecycle methods wrap the low-level +ProjectItem.move_to!+ calls,
13
- # resolve configurable status aliases, and fire
14
- # +ActiveSupport::Notifications+ events on every transition.
11
+ # Named lifecycle methods wrap the low-level +ProjectItem.move_to!+ calls, resolve configurable status aliases, and
12
+ # fire +ActiveSupport::Notifications+ events on every transition.
15
13
  #
16
14
  # No transition enforcement -- any status can move to any other.
17
15
  #
18
16
  module Pipeline
19
17
  module_function
20
18
 
21
- # Raises +PlanMyStuff::PendingApprovalsError+ when +issue+ has
22
- # pending manager approvals. No-op for +nil+ issues or issues that
23
- # either have no approvers required or are fully approved.
19
+ # Raises +PlanMyStuff::PendingApprovalsError+ when +issue+ has pending manager approvals. No-op for +nil+ issues or
20
+ # issues that either have no approvers required or are fully approved.
24
21
  #
25
- # Called at the top of every forward transition (+take!+,
26
- # +mark_in_review!+, +request_testing!+,
27
- # +mark_ready_for_release!+). Batch / CI-driven transitions
28
- # (+start_deployment!+, +complete_deployment!+) and reverse
29
- # transitions (+remove!+) are intentionally NOT gated -- earlier
30
- # forward transitions already required approval, and gating
31
- # batch/automated paths would abort entire deploys on a single
32
- # approval revoke.
22
+ # Called at the top of every forward transition (+take!+, +mark_in_review!+, +request_testing!+,
23
+ # +mark_ready_for_release!+). Batch / CI-driven transitions (+start_deployment!+, +complete_deployment!+) and
24
+ # reverse transitions (+remove!+) are intentionally NOT gated -- earlier forward transitions already required
25
+ # approval, and gating batch/automated paths would abort entire deploys on a single approval revoke.
26
+ #
27
+ # @raise [PlanMyStuff::PendingApprovalsError] when +issue+ has pending approvals
33
28
  #
34
29
  # @param issue [PlanMyStuff::Issue, nil]
35
30
  #
@@ -48,8 +43,8 @@ module PlanMyStuff
48
43
  ))
49
44
  end
50
45
 
51
- # Resolves a canonical status name to its configured display alias,
52
- # falling back to the canonical name when no alias is configured.
46
+ # Resolves a canonical status name to its configured display alias, falling back to the canonical name when no
47
+ # alias is configured.
53
48
  #
54
49
  # @param canonical [String] one of the +Pipeline::Status+ constants
55
50
  #
@@ -59,36 +54,44 @@ module PlanMyStuff
59
54
  PlanMyStuff.configuration.pipeline_statuses.fetch(canonical, canonical)
60
55
  end
61
56
 
62
- # Fires an +ActiveSupport::Notifications+ event for a pipeline transition.
57
+ # Fires a +plan_my_stuff.pipeline.<event>+ notification.
63
58
  #
64
- # @param status [String] canonical status name (used for event key)
65
- # @param project_item [PlanMyStuff::ProjectItem]
59
+ # When +event+ is a canonical +Pipeline::Status+ name, the event key is derived via +Status.key_for+ and the
60
+ # canonical status is added to the payload as +:status+. Otherwise +event+ is used verbatim as the suffix (e.g.
61
+ # +"removed"+, +"removed_late"+, +"testing"+).
62
+ #
63
+ # @param event [String] status name or literal event suffix
64
+ # @param project_item [PlanMyStuff::BaseProjectItem]
66
65
  # @param extra [Hash] additional payload entries
67
66
  #
68
67
  # @return [void]
69
68
  #
70
- def instrument(status, project_item, **extra)
71
- key = PlanMyStuff::Pipeline::Status.key_for(status)
72
- payload = {
73
- project_item: project_item,
74
- issue_number: project_item.number,
75
- status: status,
76
- timestamp: Time.current,
77
- }.merge(extra)
69
+ def instrument(event, project_item, **extra)
70
+ extra_to_use = { **extra }
71
+ event_to_use = event
72
+ if PlanMyStuff::Pipeline::Status::ALL.include?(event_to_use)
73
+ extra_to_use = { status: event, **extra_to_use }
74
+ event_to_use = PlanMyStuff::Pipeline::Status.key_for(event_to_use)
75
+ end
78
76
 
79
- ActiveSupport::Notifications.instrument("plan_my_stuff.pipeline.#{key}", payload)
77
+ PlanMyStuff::Notifications.instrument(
78
+ "pipeline.#{event_to_use}",
79
+ project_item,
80
+ issue_number: project_item.number,
81
+ **extra_to_use,
82
+ )
80
83
  end
81
84
 
82
- # Returns the pipeline project number from the explicit argument,
83
- # +pipeline_project_number+, or +default_project_number+.
85
+ # Returns the pipeline project number from the explicit argument, +pipeline_project_number+, or
86
+ # +default_project_number+.
87
+ #
88
+ # @raise [ArgumentError] if no project number can be resolved
84
89
  #
85
90
  # @param project_number [Integer, nil]
86
91
  #
87
92
  # @return [Integer]
88
93
  #
89
- # @raise [ArgumentError] if no project number can be resolved
90
- #
91
- def resolve_pipeline_project_number(project_number = nil)
94
+ def resolve_pipeline_project_number!(project_number = nil)
92
95
  config = PlanMyStuff.configuration
93
96
  result = project_number || config.pipeline_project_number || config.default_project_number
94
97
 
@@ -97,14 +100,12 @@ module PlanMyStuff
97
100
  result
98
101
  end
99
102
 
100
- # Removes a project item from the pipeline project entirely.
101
- # Captures the prior status before deletion so subscribers can
102
- # decide whether the removal happened late in the lifecycle.
103
+ # Removes a project item from the pipeline project entirely. Captures the prior status before deletion so
104
+ # subscribers can decide whether the removal happened late in the lifecycle.
103
105
  #
104
- # Always fires +plan_my_stuff.pipeline.removed+. Additionally fires
105
- # +plan_my_stuff.pipeline.removed_late+ when +prior_status+ is past
106
- # "Started" (i.e. anything other than "Started", accounting for
107
- # configured status aliases). +nil+ status is treated as not-late.
106
+ # Always fires +plan_my_stuff.pipeline.removed+. Additionally fires +plan_my_stuff.pipeline.removed_late+ when
107
+ # +prior_status+ is past "Started" (i.e. anything other than "Started", accounting for configured status aliases).
108
+ # +nil+ status is treated as not-late.
108
109
  #
109
110
  # @param project_item [PlanMyStuff::ProjectItem]
110
111
  #
@@ -114,24 +115,14 @@ module PlanMyStuff
114
115
  prior_status = project_item.status
115
116
  result = project_item.destroy!
116
117
 
117
- payload = {
118
- project_item: project_item,
119
- issue_number: project_item.number,
120
- prior_status: prior_status,
121
- timestamp: Time.current,
122
- }
123
-
124
- ActiveSupport::Notifications.instrument('plan_my_stuff.pipeline.removed', payload)
125
-
126
- if late_removal?(prior_status)
127
- ActiveSupport::Notifications.instrument('plan_my_stuff.pipeline.removed_late', payload)
128
- end
118
+ instrument('removed', project_item, prior_status: prior_status)
119
+ instrument('removed_late', project_item, prior_status: prior_status) if late_removal?(prior_status)
129
120
 
130
121
  result
131
122
  end
132
123
 
133
- # Returns true when +prior_status+ is past "Started" in the pipeline.
134
- # Honors configured status aliases. +nil+ is treated as not-late.
124
+ # Returns true when +prior_status+ is past "Started" in the pipeline. Honors configured status aliases. +nil+ is
125
+ # treated as not-late.
135
126
  #
136
127
  # @param prior_status [String, nil]
137
128
  #
@@ -144,13 +135,11 @@ module PlanMyStuff
144
135
  prior_status != started
145
136
  end
146
137
 
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.
138
+ # Returns true when +project_item+ is at a release-cycle status ("Ready for Release", "Release in Progress", or
139
+ # "Completed"). Honors configured status aliases.
150
140
  #
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".
141
+ # Used to lock items against webhook-driven removals once they enter the release path -- a stray unassign should
142
+ # not yank an item out of "Release in Progress".
154
143
  #
155
144
  # @param project_item [PlanMyStuff::BaseProjectItem]
156
145
  #
@@ -196,9 +185,8 @@ module PlanMyStuff
196
185
  result
197
186
  end
198
187
 
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+.
188
+ # Marks a project item as in testing by setting the +Testing+ custom single-select field to its active value. Does
189
+ # NOT change the item's +Status+ -- testing runs orthogonally to +In Review+.
202
190
  #
203
191
  # @param project_item [PlanMyStuff::ProjectItem]
204
192
  #
@@ -210,14 +198,7 @@ module PlanMyStuff
210
198
  value = PlanMyStuff.configuration.pipeline_testing_values.fetch(:active)
211
199
  result = project_item.update_single_select_field!(field_name, value)
212
200
 
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
- )
201
+ instrument('testing', project_item, field_name: field_name, value: value)
221
202
 
222
203
  result
223
204
  end
@@ -237,14 +218,12 @@ module PlanMyStuff
237
218
  result
238
219
  end
239
220
 
240
- # Finds ALL items at "Ready for Release" in the pipeline project and
241
- # moves each to "Release in Progress". Fires an event per item.
221
+ # Finds ALL items at "Ready for Release" in the pipeline project and moves each to "Release in Progress". Fires an
222
+ # event per item.
242
223
  #
243
- # When +commit_sha+ is given (the merge_commit_sha of the PR into
244
- # +production+), it is stamped onto every moved item's linked issue
245
- # metadata. +AwsController#handle_deployment_completed+ later matches
246
- # this sha against the configured +production_commit_sha+ to decide
247
- # which items to auto-complete, so this is the only commit sha in the
224
+ # When +commit_sha+ is given (the merge_commit_sha of the PR into +production+), it is stamped onto every moved
225
+ # item's linked issue metadata. +AwsController#handle_deployment_completed+ later matches this sha against the
226
+ # configured +production_commit_sha+ to decide which items to auto-complete, so this is the only commit sha in the
248
227
  # release cycle that matters.
249
228
  #
250
229
  # @param project_number [Integer, nil]
@@ -253,7 +232,7 @@ module PlanMyStuff
253
232
  # @return [Array<PlanMyStuff::ProjectItem>]
254
233
  #
255
234
  def start_deployment!(project_number: nil, commit_sha: nil)
256
- number = resolve_pipeline_project_number(project_number)
235
+ number = resolve_pipeline_project_number!(project_number)
257
236
  project = PlanMyStuff::Project.find(number)
258
237
 
259
238
  ready_status = resolve_status_name(PlanMyStuff::Pipeline::Status::READY_FOR_RELEASE)
@@ -275,9 +254,8 @@ module PlanMyStuff
275
254
  items
276
255
  end
277
256
 
278
- # Moves a project item to "Completed" if the linked issue has
279
- # +auto_complete+ enabled. Returns +nil+ when auto-complete is off
280
- # (item stays at "Release in Progress").
257
+ # Moves a project item to "Completed" if the linked issue has +auto_complete+ enabled. Returns +nil+ when
258
+ # auto-complete is off (item stays at "Release in Progress").
281
259
  #
282
260
  # @param project_item [PlanMyStuff::ProjectItem]
283
261
  # @param deployment_id [String, nil]
@@ -44,7 +44,7 @@ module PlanMyStuff
44
44
  project_id = new_project[:id]
45
45
  project_number = new_project[:number]
46
46
 
47
- serialized_readme = PlanMyStuff::MetadataParser.serialize(project_metadata.to_h, readme)
47
+ serialized_readme = PlanMyStuff::MetadataParser.serialize!(project_metadata.to_h, readme)
48
48
  update_input = { projectId: project_id, readme: serialized_readme }
49
49
  update_input[:shortDescription] = description if description.present?
50
50
 
@@ -60,12 +60,12 @@ module PlanMyStuff
60
60
  # project - call +TestingProject.find+ for those, or +BaseProject.find+
61
61
  # if you want either type.
62
62
  #
63
+ # @raise [ArgumentError] if the project is a testing project
64
+ #
63
65
  # @param number [Integer]
64
66
  # @param paginate [Symbol] :auto (default) or :cursor
65
67
  # @param cursor [String, nil] pagination cursor for :cursor mode
66
68
  #
67
- # @raise [ArgumentError] if the project is a testing project
68
- #
69
69
  # @return [PlanMyStuff::Project]
70
70
  #
71
71
  def find(number, paginate: :auto, cursor: nil)
@@ -91,7 +91,7 @@ module PlanMyStuff
91
91
 
92
92
  private
93
93
 
94
- # Returns the item class used to build items for testing projects.
94
+ # Returns the item class used to build items for regular projects.
95
95
  #
96
96
  # @return [Class]
97
97
  #
@@ -10,7 +10,7 @@ module PlanMyStuff
10
10
  # (T-049), which will assign non-GitHub users (e.g. internal QA
11
11
  # accounts) to draft items via the +pms_assignee+ field.
12
12
  #
13
- class ProjectItemMetadata < BaseMetadata
13
+ class ProjectItemMetadata < PlanMyStuff::BaseMetadata
14
14
  # @return [Integer, nil] consuming app user id of the PMS-side assignee
15
15
  attr_accessor :pms_assignee
16
16
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module PlanMyStuff
4
4
  # Metadata for regular (non-testing) projects.
5
- class ProjectMetadata < BaseProjectMetadata
5
+ class ProjectMetadata < PlanMyStuff::BaseProjectMetadata
6
6
  class << self
7
7
  # Builds a ProjectMetadata from a parsed hash (e.g. from MetadataParser)
8
8
  #
@@ -63,7 +63,7 @@ module PlanMyStuff
63
63
  return if @issue.labels.include?(label)
64
64
 
65
65
  PlanMyStuff::Label.ensure!(repo: @issue.repo, name: label)
66
- PlanMyStuff::Label.add(issue: @issue, labels: [label])
66
+ PlanMyStuff::Label.add!(issue: @issue, labels: [label])
67
67
  end
68
68
  end
69
69
  end