plan_my_stuff 0.3.0 → 0.4.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +569 -38
  4. data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
  5. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
  6. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
  7. data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
  9. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
  10. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
  11. data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
  12. data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
  13. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
  14. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
  15. data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
  16. data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
  17. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
  18. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
  19. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
  20. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
  21. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
  22. data/app/jobs/plan_my_stuff/application_job.rb +9 -0
  23. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
  24. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
  25. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
  26. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
  27. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
  28. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
  30. data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
  31. data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
  32. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  33. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  34. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  35. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  36. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  37. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  38. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  39. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  40. data/config/routes.rb +38 -15
  41. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  42. data/lib/plan_my_stuff/application_record.rb +121 -0
  43. data/lib/plan_my_stuff/approval.rb +80 -0
  44. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  45. data/lib/plan_my_stuff/archive.rb +14 -0
  46. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  47. data/lib/plan_my_stuff/base_project.rb +661 -0
  48. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  49. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  50. data/lib/plan_my_stuff/cache.rb +197 -0
  51. data/lib/plan_my_stuff/client.rb +7 -0
  52. data/lib/plan_my_stuff/comment.rb +171 -50
  53. data/lib/plan_my_stuff/configuration.rb +210 -10
  54. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  55. data/lib/plan_my_stuff/engine.rb +0 -4
  56. data/lib/plan_my_stuff/errors.rb +49 -0
  57. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  58. data/lib/plan_my_stuff/issue.rb +1476 -175
  59. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  60. data/lib/plan_my_stuff/label.rb +82 -11
  61. data/lib/plan_my_stuff/link.rb +144 -0
  62. data/lib/plan_my_stuff/notifications.rb +142 -0
  63. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  64. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  65. data/lib/plan_my_stuff/pipeline.rb +293 -0
  66. data/lib/plan_my_stuff/project.rb +30 -693
  67. data/lib/plan_my_stuff/project_item.rb +3 -417
  68. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  69. data/lib/plan_my_stuff/project_metadata.rb +9 -3
  70. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  71. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  72. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  73. data/lib/plan_my_stuff/reminders.rb +16 -0
  74. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  75. data/lib/plan_my_stuff/testing_project.rb +291 -0
  76. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  77. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  78. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  79. data/lib/plan_my_stuff/version.rb +1 -1
  80. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  81. data/lib/plan_my_stuff.rb +15 -0
  82. data/lib/tasks/plan_my_stuff.rake +163 -0
  83. metadata +50 -2
@@ -12,6 +12,30 @@ module PlanMyStuff
12
12
  attr_accessor :priority_list_priority
13
13
  # @return [Array<Integer>] user IDs of non-support users allowed to view internal comments
14
14
  attr_accessor :visibility_allowlist
15
+ # @return [String, nil] merged PR commit SHA for release tracking
16
+ attr_accessor :commit_sha
17
+ # @return [Boolean] whether to auto-complete on deployment (default: true)
18
+ attr_accessor :auto_complete
19
+ # @return [Array<PlanMyStuff::Link>] metadata-backed issue relationships.
20
+ # Only +:related+ links live here; native relationships (blocking,
21
+ # parent, sub_ticket, duplicate_of) live on GitHub.
22
+ attr_accessor :links
23
+ # @return [Array<PlanMyStuff::Approval>] manager approvals required on
24
+ # this issue. See +Issue.request_approvals!+ and +Issue#fully_approved?+.
25
+ attr_accessor :approvals
26
+ # @return [Time, nil] when the issue entered "waiting on user reply" state
27
+ attr_accessor :waiting_on_user_at
28
+ # @return [Time, nil] when the issue entered "waiting on approval" state
29
+ attr_accessor :waiting_on_approval_at
30
+ # @return [Time, nil] when the next reminder event should fire for this issue
31
+ attr_accessor :next_reminder_at
32
+ # @return [Array<Integer>, nil] per-issue override of +config.reminder_days+;
33
+ # applies to both waiting kinds
34
+ attr_accessor :reminder_days
35
+ # @return [Boolean] whether this issue was auto-closed by the inactivity sweep
36
+ attr_accessor :closed_by_inactivity
37
+ # @return [Time, nil] when the archive sweep tagged this issue as archived
38
+ attr_accessor :archived_at
15
39
 
16
40
  class << self
17
41
  # Builds an IssueMetadata from a parsed hash (e.g. from MetadataParser)
@@ -29,6 +53,16 @@ module PlanMyStuff
29
53
  metadata.priority_list = hash.fetch(:priority_list, false)
30
54
  metadata.priority_list_priority = hash.fetch(:priority_list_priority, -1)
31
55
  metadata.visibility_allowlist = Array.wrap(hash[:visibility_allowlist])
56
+ metadata.commit_sha = hash[:commit_sha]
57
+ metadata.auto_complete = hash.fetch(:auto_complete, true)
58
+ metadata.links = normalize_links(hash[:links])
59
+ metadata.approvals = normalize_approvals(hash[:approvals])
60
+ metadata.waiting_on_user_at = parse_time(hash[:waiting_on_user_at])
61
+ metadata.waiting_on_approval_at = parse_time(hash[:waiting_on_approval_at])
62
+ metadata.next_reminder_at = parse_time(hash[:next_reminder_at])
63
+ metadata.reminder_days = normalize_reminder_days(hash[:reminder_days])
64
+ metadata.closed_by_inactivity = hash.fetch(:closed_by_inactivity, false)
65
+ metadata.archived_at = parse_time(hash[:archived_at])
32
66
 
33
67
  metadata
34
68
  end
@@ -55,6 +89,16 @@ module PlanMyStuff
55
89
  metadata.priority_list = false
56
90
  metadata.priority_list_priority = -1
57
91
  metadata.visibility_allowlist = []
92
+ metadata.commit_sha = nil
93
+ metadata.auto_complete = true
94
+ metadata.links = []
95
+ metadata.approvals = []
96
+ metadata.waiting_on_user_at = nil
97
+ metadata.waiting_on_approval_at = nil
98
+ metadata.next_reminder_at = nil
99
+ metadata.reminder_days = nil
100
+ metadata.closed_by_inactivity = false
101
+ metadata.archived_at = nil
58
102
 
59
103
  metadata
60
104
  end
@@ -67,6 +111,60 @@ module PlanMyStuff
67
111
 
68
112
  config.issues_url_prefix.to_s
69
113
  end
114
+
115
+ # Builds a +PlanMyStuff::Link+ from each parsed entry. Malformed
116
+ # entries (wrong shape, missing fields, invalid values) are
117
+ # silently dropped so a single bad entry doesn't crash
118
+ # +Issue.find+ for an otherwise healthy issue.
119
+ #
120
+ # @param raw [Array, nil]
121
+ #
122
+ # @return [Array<PlanMyStuff::Link>]
123
+ #
124
+ def normalize_links(raw)
125
+ Array.wrap(raw).filter_map do |entry|
126
+ PlanMyStuff::Link.build(entry)
127
+ rescue ActiveModel::ValidationError, ArgumentError
128
+ next
129
+ end
130
+ end
131
+
132
+ # Builds a +PlanMyStuff::Approval+ from each parsed entry.
133
+ # Malformed entries (wrong shape, missing fields, invalid values,
134
+ # unknown attributes) are silently dropped so a single bad entry
135
+ # doesn't crash +Issue.find+ for an otherwise healthy issue.
136
+ #
137
+ # @param raw [Array, nil]
138
+ #
139
+ # @return [Array<PlanMyStuff::Approval>]
140
+ #
141
+ def normalize_approvals(raw)
142
+ Array.wrap(raw).filter_map do |entry|
143
+ approval = PlanMyStuff::Approval.new(entry.transform_keys(&:to_sym))
144
+ approval.validate!
145
+ approval
146
+ rescue ActiveModel::ValidationError, ArgumentError, NoMethodError
147
+ next
148
+ end
149
+ end
150
+
151
+ # Normalizes a raw +reminder_days+ value from parsed metadata.
152
+ # Returns +nil+ when absent so callers can fall back to config;
153
+ # otherwise returns the array with non-integer entries dropped.
154
+ #
155
+ # @param raw [Object]
156
+ #
157
+ # @return [Array<Integer>, nil]
158
+ #
159
+ def normalize_reminder_days(raw)
160
+ return if raw.nil?
161
+
162
+ Array.wrap(raw).filter_map do |entry|
163
+ Integer(entry)
164
+ rescue ArgumentError, TypeError
165
+ next
166
+ end
167
+ end
70
168
  end
71
169
 
72
170
  def initialize
@@ -74,6 +172,20 @@ module PlanMyStuff
74
172
  @priority_list = false
75
173
  @priority_list_priority = -1
76
174
  @visibility_allowlist = []
175
+ @auto_complete = true
176
+ @links = []
177
+ @approvals = []
178
+ @waiting_on_user_at = nil
179
+ @waiting_on_approval_at = nil
180
+ @next_reminder_at = nil
181
+ @reminder_days = nil
182
+ @closed_by_inactivity = false
183
+ @archived_at = nil
184
+ end
185
+
186
+ # @return [Boolean]
187
+ def auto_complete?
188
+ !!auto_complete
77
189
  end
78
190
 
79
191
  # @return [Boolean]
@@ -111,6 +223,16 @@ module PlanMyStuff
111
223
  priority_list: priority_list,
112
224
  priority_list_priority: priority_list_priority,
113
225
  visibility_allowlist: visibility_allowlist,
226
+ commit_sha: commit_sha,
227
+ auto_complete: auto_complete,
228
+ links: links.map(&:to_h),
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),
233
+ reminder_days: reminder_days,
234
+ closed_by_inactivity: closed_by_inactivity,
235
+ archived_at: format_time(archived_at),
114
236
  )
115
237
  end
116
238
  end
@@ -4,43 +4,104 @@ module PlanMyStuff
4
4
  # Wraps a GitHub label with a reference to its parent issue.
5
5
  # Class methods provide the public API for add/remove operations.
6
6
  class Label < PlanMyStuff::ApplicationRecord
7
- # @return [String] label name
8
- attr_accessor :name
9
- # @return [PlanMyStuff::Issue] parent issue
10
- attr_accessor :issue
7
+ # @return [String, nil]
8
+ attribute :name, :string
9
+ # @return [PlanMyStuff::Issue, nil]
10
+ attribute :issue
11
11
 
12
12
  class << self
13
13
  # Adds labels to a GitHub issue.
14
14
  #
15
15
  # @param issue [PlanMyStuff::Issue] parent issue
16
16
  # @param labels [Array<String>]
17
+ # @param user [Object, nil] actor for the notification event
17
18
  #
18
19
  # @return [Array<PlanMyStuff::Label>]
19
20
  #
20
- def add(issue:, labels:)
21
+ def add(issue:, labels:, user: nil)
22
+ label_names = Array.wrap(labels)
23
+
21
24
  result = PlanMyStuff.client.rest(
22
- :add_labels_to_an_issue, issue.repo, issue.number, labels,
25
+ :add_labels_to_an_issue, issue.repo, issue.number, label_names,
26
+ )
27
+
28
+ PMS::Cache.delete_issue(issue.repo, issue.number)
29
+
30
+ PlanMyStuff::Notifications.instrument(
31
+ 'label.added', issue, user: user, labels: label_names,
23
32
  )
24
33
 
25
34
  result.map { |gh_label| build(gh_label, issue: issue) }
26
35
  end
27
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.
40
+ #
41
+ # @param repo [String, Symbol] repo name or key
42
+ # @param name [String] label name
43
+ # @param color [String] hex color without +#+
44
+ # @param description [String, nil]
45
+ #
46
+ # @return [void]
47
+ #
48
+ def ensure!(repo:, name:, color: 'fbca04', description: nil)
49
+ client = PlanMyStuff.client
50
+ client.rest(:label, repo, name)
51
+ rescue PlanMyStuff::APIError => e
52
+ raise unless e.status == 404
53
+
54
+ create_label(client, repo, name, color, description)
55
+ end
56
+
28
57
  # Removes labels from a GitHub issue.
29
58
  #
30
59
  # @param issue [PlanMyStuff::Issue] parent issue
31
60
  # @param labels [Array<String>]
61
+ # @param user [Object, nil] actor for the notification event
32
62
  #
33
63
  # @return [Array<Array<PlanMyStuff::Label>>] results of each removal
34
64
  #
35
- def remove(issue:, labels:)
36
- Array.wrap(labels).map do |label|
65
+ def remove(issue:, labels:, user: nil)
66
+ label_names = Array.wrap(labels)
67
+
68
+ results = label_names.map do |label|
37
69
  result = PlanMyStuff.client.rest(:remove_label, issue.repo, issue.number, label)
38
70
  result.map { |gh_label| build(gh_label, issue: issue) }
39
71
  end
72
+
73
+ PMS::Cache.delete_issue(issue.repo, issue.number)
74
+
75
+ PlanMyStuff::Notifications.instrument(
76
+ 'label.removed', issue, user: user, labels: label_names,
77
+ )
78
+
79
+ results
40
80
  end
41
81
 
42
82
  private
43
83
 
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.
87
+ #
88
+ # @param client [PlanMyStuff::Client]
89
+ # @param repo [String, Symbol]
90
+ # @param name [String]
91
+ # @param color [String]
92
+ # @param description [String, nil]
93
+ #
94
+ # @return [void]
95
+ #
96
+ def create_label(client, repo, name, color, description)
97
+ options = {}
98
+ options[:description] = description if description
99
+
100
+ client.rest(:add_label, repo, name, color, **options)
101
+ rescue PlanMyStuff::APIError => e
102
+ raise unless e.status == 422
103
+ end
104
+
44
105
  # Hydrates a Label from a GitHub API response.
45
106
  #
46
107
  # @param github_label [Object] Octokit label response
@@ -49,11 +110,21 @@ module PlanMyStuff
49
110
  # @return [PlanMyStuff::Label]
50
111
  #
51
112
  def build(github_label, issue:)
52
- label_name = github_label.respond_to?(:name) ? github_label.name : github_label[:name]
53
- label = new(name: label_name, issue: issue)
54
- label.instance_variable_set(:@persisted, true)
113
+ label = new(name: read_field(github_label, :name), issue: issue)
114
+ label.instance_variable_set(:@github_response, github_label)
115
+ label.__send__(:persisted!)
55
116
  label
56
117
  end
57
118
  end
119
+
120
+ # Serializes the label to a JSON-safe hash, excluding the back-reference
121
+ # to the parent issue to prevent recursive serialization cycles.
122
+ #
123
+ # @return [Hash]
124
+ #
125
+ def as_json(options = {})
126
+ merged_except = Array.wrap(options[:except]) + ['issue']
127
+ super(options.merge(except: merged_except)).merge('issue_number' => issue&.number)
128
+ end
58
129
  end
59
130
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
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"+.
11
+ #
12
+ # Metadata-backed types live in +IssueMetadata#links+; native types
13
+ # are routed through GitHub APIs and never persisted in our metadata.
14
+ class Link
15
+ METADATA_TYPES = %w[related].freeze
16
+ NATIVE_TYPES = %w[blocking blocked_by parent sub_ticket duplicate_of].freeze
17
+ ALL_TYPES = (METADATA_TYPES + NATIVE_TYPES).freeze
18
+
19
+ include ActiveModel::Model
20
+ include ActiveModel::Attributes
21
+ include ActiveModel::Serializers::JSON
22
+
23
+ # @return [String] one of ALL_TYPES
24
+ attribute :type, :string
25
+ # @return [Integer] target issue's GitHub issue number
26
+ attribute :issue_number, :integer
27
+ # @return [String] full "owner/name" path of the target issue's repo
28
+ attribute :repo, :string
29
+
30
+ validates :type, presence: true, inclusion: { in: ALL_TYPES }
31
+ validates :issue_number, presence: true, numericality: { greater_than: 0, only_integer: true }
32
+ validates :repo, presence: true
33
+
34
+ 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.
40
+ #
41
+ # @param input [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
42
+ # @param type [String, Symbol, nil]
43
+ # @param source_repo [String, PlanMyStuff::Repo, nil]
44
+ #
45
+ # @return [PlanMyStuff::Link]
46
+ #
47
+ def build(input, type: nil, source_repo: nil)
48
+ return input if input.is_a?(PlanMyStuff::Link)
49
+
50
+ link =
51
+ if input.is_a?(Hash)
52
+ build_from_hash(input, type: type, source_repo: source_repo)
53
+ else
54
+ build_from_issue_like(input, type: type)
55
+ end
56
+
57
+ link.validate!
58
+ link
59
+ end
60
+
61
+ private
62
+
63
+ # @return [PlanMyStuff::Link]
64
+ def build_from_issue_like(input, type:)
65
+ if !input.respond_to?(:number) || !input.respond_to?(:repo)
66
+ raise(ArgumentError, "Cannot build Link from #{input.class}")
67
+ end
68
+
69
+ new(
70
+ type: type&.to_s,
71
+ issue_number: input.number,
72
+ repo: repo_string(input.repo),
73
+ )
74
+ end
75
+
76
+ # @return [PlanMyStuff::Link]
77
+ def build_from_hash(hash, type:, source_repo:)
78
+ data = hash.transform_keys(&:to_sym)
79
+ new(
80
+ type: (data[:type] || type)&.to_s,
81
+ issue_number: data[:issue_number],
82
+ repo: repo_string(data[:repo] || source_repo),
83
+ )
84
+ end
85
+
86
+ # @return [String, nil]
87
+ def repo_string(value)
88
+ return if value.nil?
89
+ return value.full_name if value.is_a?(PlanMyStuff::Repo)
90
+
91
+ value.to_s
92
+ end
93
+ end
94
+
95
+ # @return [String] e.g. "owner/repo#42"
96
+ def to_s
97
+ "#{repo}##{issue_number}"
98
+ end
99
+
100
+ # @return [Hash]
101
+ def to_h
102
+ {
103
+ type: type,
104
+ issue_number: issue_number,
105
+ repo: repo,
106
+ }
107
+ end
108
+
109
+ # @param other_repo [String, PlanMyStuff::Repo, nil]
110
+ #
111
+ # @return [Boolean]
112
+ #
113
+ def same_repo?(other_repo)
114
+ return false if other_repo.nil?
115
+
116
+ repo == self.class.__send__(:repo_string, other_repo)
117
+ end
118
+
119
+ # Lazy-fetches and memoizes the target +Issue+.
120
+ #
121
+ # @return [PlanMyStuff::Issue]
122
+ #
123
+ def issue
124
+ @issue ||= PlanMyStuff::Issue.find(issue_number, repo: repo)
125
+ end
126
+
127
+ # @param other [Object]
128
+ #
129
+ # @return [Boolean]
130
+ #
131
+ def ==(other)
132
+ return false unless other.is_a?(PlanMyStuff::Link)
133
+
134
+ type == other.type && issue_number == other.issue_number && repo == other.repo
135
+ end
136
+
137
+ alias eql? ==
138
+
139
+ # @return [Integer]
140
+ def hash
141
+ [type, issue_number, repo].hash
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/notifications'
4
+
5
+ module PlanMyStuff
6
+ # Central instrumentation helper. Domain classes call
7
+ # +PlanMyStuff::Notifications.instrument+ at mutation points so
8
+ # consuming apps can subscribe for email, webhooks, Slack, etc.
9
+ #
10
+ # Events are fired under the +plan_my_stuff.<event>+ namespace via
11
+ # +ActiveSupport::Notifications+. Subscribers run synchronously.
12
+ #
13
+ module Notifications
14
+ module_function
15
+
16
+ EVENT_PREFIX = 'plan_my_stuff'
17
+ SKIPPED_LOG_KEYS = %i[user timestamp visibility visibility_allowlist].freeze
18
+
19
+ # Fires +plan_my_stuff.<event>+ with a normalized payload.
20
+ #
21
+ # @param event [String] e.g. +'issue.created'+
22
+ # @param resource [Object] domain object (+Issue+, +Comment+, +ProjectItem+, ...)
23
+ # @param user [Object, nil] explicit actor; falls back to +config.current_user+
24
+ # @param extra [Hash] additional payload entries (+changes:+, +labels:+, +user_ids:+, ...)
25
+ #
26
+ # @return [void]
27
+ #
28
+ def instrument(event, resource, user: nil, **extra)
29
+ actor = user || resolve_current_user
30
+ payload = build_payload(resource, actor, extra)
31
+ log(event, payload)
32
+ ActiveSupport::Notifications.instrument("#{EVENT_PREFIX}.#{event}", payload)
33
+ end
34
+
35
+ # Invokes +config.current_user+ if it responds to +call+.
36
+ #
37
+ # @return [Object, nil]
38
+ #
39
+ def resolve_current_user
40
+ resolver = PlanMyStuff.configuration.current_user
41
+ return if resolver.nil?
42
+
43
+ resolver.respond_to?(:call) ? resolver.call : resolver
44
+ end
45
+
46
+ # Builds the payload hash for an event.
47
+ #
48
+ # @param resource [Object]
49
+ # @param actor [Object, nil]
50
+ # @param extra [Hash]
51
+ #
52
+ # @return [Hash]
53
+ #
54
+ def build_payload(resource, actor, extra)
55
+ payload = {
56
+ infer_resource_key(resource) => resource,
57
+ :user => actor,
58
+ :timestamp => Time.current,
59
+ }
60
+ payload.merge!(visibility_fields(resource))
61
+ payload.merge(extra)
62
+ end
63
+
64
+ # Maps a resource object to its payload key.
65
+ #
66
+ # @param resource [Object]
67
+ #
68
+ # @return [Symbol]
69
+ #
70
+ def infer_resource_key(resource)
71
+ case resource
72
+ when PlanMyStuff::Issue then :issue
73
+ when PlanMyStuff::Comment then :comment
74
+ when PlanMyStuff::BaseProjectItem then :project_item
75
+ else :resource
76
+ end
77
+ end
78
+
79
+ # Extracts visibility + allowlist from +Issue+/+Comment+ resources.
80
+ # Returns an empty hash for resources without visibility.
81
+ #
82
+ # @param resource [Object]
83
+ #
84
+ # @return [Hash]
85
+ #
86
+ def visibility_fields(resource)
87
+ case resource
88
+ when PlanMyStuff::Issue
89
+ {
90
+ visibility: resource.metadata.visibility,
91
+ visibility_allowlist: Array.wrap(resource.metadata.visibility_allowlist),
92
+ }
93
+ when PlanMyStuff::Comment
94
+ parent_allowlist = resource.issue ? resource.issue.metadata.visibility_allowlist : []
95
+ {
96
+ visibility: resource.visibility&.to_s,
97
+ visibility_allowlist: Array.wrap(parent_allowlist),
98
+ }
99
+ else
100
+ {}
101
+ end
102
+ end
103
+
104
+ # Emits a debug log line for the event. No-op when no logger is
105
+ # available (e.g. outside Rails).
106
+ #
107
+ # @param event [String]
108
+ # @param payload [Hash]
109
+ #
110
+ # @return [void]
111
+ #
112
+ def log(event, payload)
113
+ logger = rails_logger
114
+ return if logger.nil?
115
+
116
+ logger.debug { "[PMS] #{EVENT_PREFIX}.#{event} #{log_fields(payload)}" }
117
+ end
118
+
119
+ # @return [Logger, nil]
120
+ def rails_logger
121
+ return unless defined?(Rails)
122
+ return unless Rails.respond_to?(:logger)
123
+
124
+ Rails.logger
125
+ end
126
+
127
+ # @param payload [Hash]
128
+ #
129
+ # @return [String]
130
+ #
131
+ def log_fields(payload)
132
+ fields = []
133
+ fields << "user=#{payload[:user].inspect}" if payload.key?(:user)
134
+ payload.each do |key, value|
135
+ next if SKIPPED_LOG_KEYS.include?(key)
136
+
137
+ fields << "#{key}=#{value.inspect}"
138
+ end
139
+ fields.join(' ')
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Pipeline
5
+ # Resolves linked issues from pull request payloads and finds their
6
+ # corresponding ProjectItems in the pipeline project.
7
+ #
8
+ # Uses +module_function+ pattern (matches +MetadataParser+, +Pipeline+).
9
+ #
10
+ module IssueLinker
11
+ module_function
12
+
13
+ # Regex matching GitHub closing keywords in PR bodies.
14
+ # Matches all nine variants: close/closes/closed, fix/fixes/fixed,
15
+ # resolve/resolves/resolved (case-insensitive).
16
+ CLOSING_KEYWORD_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/i
17
+
18
+ # Extracts issue numbers from a PR payload by parsing the PR body
19
+ # and commit messages for GitHub closing keywords (close/closes/closed,
20
+ # fix/fixes/fixed, resolve/resolves/resolved).
21
+ #
22
+ # @param pr_payload [Hash] parsed PR webhook payload (expects +:body+ or +"body"+ key)
23
+ # @param commit_messages [Array<String>] commit messages to scan (default: [])
24
+ #
25
+ # @return [Array<Integer>] referenced issue numbers (empty if none found)
26
+ #
27
+ def extract_issue_numbers(pr_payload, commit_messages: [])
28
+ texts = []
29
+
30
+ body = pr_payload[:body] || pr_payload['body']
31
+ texts << body if body.present?
32
+
33
+ Array.wrap(commit_messages).each do |message|
34
+ texts << message if message.present?
35
+ end
36
+
37
+ return [] if texts.empty?
38
+
39
+ matches = texts.join("\n").scan(CLOSING_KEYWORD_PATTERN).flatten
40
+ matches.map!(&:to_i)
41
+ matches.uniq!
42
+ matches
43
+ end
44
+
45
+ # Finds the ProjectItem in the pipeline project that matches the given
46
+ # issue number. Loads the full project and iterates its items.
47
+ #
48
+ # @param issue_number [Integer] issue number to find
49
+ # @param repo [Symbol, String, nil] unused, reserved for future multi-repo support
50
+ # @param project_number [Integer, nil] pipeline project number
51
+ #
52
+ # @return [PlanMyStuff::ProjectItem, nil] matching item, or nil if not found
53
+ #
54
+ def find_project_item(issue_number, _repo: nil, project_number: nil)
55
+ number = PlanMyStuff::Pipeline.resolve_pipeline_project_number(project_number)
56
+ project = PlanMyStuff::Project.find(number)
57
+
58
+ project.items.find { |item| item.number == issue_number }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Pipeline
5
+ # Canonical pipeline status constants.
6
+ #
7
+ # Each constant holds the human-readable status name that maps to a
8
+ # GitHub Projects V2 single-select field value. Consuming apps can
9
+ # override display names via +PlanMyStuff.configuration.pipeline_statuses+
10
+ # but the canonical names here remain the internal identifiers.
11
+ #
12
+ module Status
13
+ SUBMITTED = 'Submitted'
14
+ STARTED = 'Started'
15
+ IN_REVIEW = 'In Review'
16
+ TESTING = 'Testing'
17
+ READY_FOR_RELEASE = 'Ready for Release'
18
+ RELEASE_IN_PROGRESS = 'Release in Progress'
19
+ COMPLETED = 'Completed'
20
+
21
+ # All statuses in pipeline order (frozen).
22
+ ALL = [
23
+ SUBMITTED,
24
+ STARTED,
25
+ IN_REVIEW,
26
+ TESTING,
27
+ READY_FOR_RELEASE,
28
+ RELEASE_IN_PROGRESS,
29
+ COMPLETED,
30
+ ].freeze
31
+
32
+ # Converts a canonical status name to a snake_case key suitable for
33
+ # notification event names.
34
+ #
35
+ # @param canonical [String] one of the canonical status names
36
+ #
37
+ # @return [String] snake_case key (e.g. +"ready_for_release"+)
38
+ #
39
+ def self.key_for(canonical)
40
+ canonical.downcase.tr(' ', '_')
41
+ end
42
+ end
43
+ end
44
+ end