plan_my_stuff 0.3.0 → 0.5.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 +22 -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 +172 -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 +216 -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 +179 -0
  83. metadata +77 -3
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'net/http'
6
+ require 'uri'
7
+
8
+ module PlanMyStuff
9
+ # Dev helper: fetch recent GitHub webhook deliveries via the GitHub API
10
+ # and POST each one to a local endpoint, so a webhook flow can be
11
+ # reproduced against a running server without waiting for GitHub to
12
+ # re-deliver.
13
+ #
14
+ # Intended for use via +plan_my_stuff:webhooks:replay+, not production
15
+ # code paths.
16
+ module WebhookReplayer
17
+ module_function
18
+
19
+ # POST a single webhook delivery to a local endpoint.
20
+ #
21
+ # @param headers [Hash]
22
+ # @param payload [String, Hash, Array]
23
+ # @param endpoint_url [String] full URL including path
24
+ #
25
+ # @return [Net::HTTPResponse]
26
+ #
27
+ def post(headers:, payload:, endpoint_url:)
28
+ uri = URI(endpoint_url)
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = uri.scheme == 'https'
31
+ http.open_timeout = 3_600
32
+ http.read_timeout = 3_600
33
+
34
+ body = payload.is_a?(String) ? JSON.parse(payload).to_json : JSON.generate(payload)
35
+
36
+ request = Net::HTTP::Post.new(uri.request_uri)
37
+ headers.each { |key, value| request[key.to_s] = value.to_s }
38
+ request['Content-Type'] ||= 'application/json'
39
+ request.body = body
40
+
41
+ $stdout.puts("POST #{endpoint_url}")
42
+ $stdout.puts("Headers: #{headers.keys.join(', ')}")
43
+ $stdout.puts('---')
44
+
45
+ response = http.request(request)
46
+ $stdout.puts("HTTP #{response.code} #{response.message}")
47
+ $stdout.puts(response.body) if response.body.present?
48
+ response
49
+ end
50
+
51
+ # Fetch deliveries for a hook and replay unseen ones.
52
+ #
53
+ # @param endpoint_url [String] local URL to POST replays to
54
+ # @param webhook_url [String] remote config.url used to resolve hook id
55
+ # @param scope [Symbol, String] :org or :repo
56
+ # @param repo [String, nil] required when scope is :repo
57
+ # @param processed_file [String] state file tracking replayed delivery ids
58
+ # @param interactive [Boolean] prompt after each successful delivery
59
+ #
60
+ # @return [void]
61
+ #
62
+ def fetch_and_replay(endpoint_url:, webhook_url:, processed_file:, scope: :org, repo: nil, interactive: true)
63
+ scope = scope.to_sym
64
+ hook_id = resolve_hook_id(scope: scope, repo: repo, webhook_url: webhook_url)
65
+ deliveries_path = "#{hooks_base_path(scope: scope, repo: repo)}/#{hook_id}/deliveries"
66
+ $stdout.puts("Using #{scope} hook #{hook_id} -> #{webhook_url}")
67
+
68
+ processed = load_processed(processed_file)
69
+ $stdout.puts("Already processed: #{processed.size}")
70
+
71
+ deliveries = gh_get(deliveries_path, per_page: 50).sort_by { |d| d['delivered_at'].to_s }
72
+ to_process = deliveries.reject { |d| processed.include?(d['id'].to_s) }
73
+ $stdout.puts("Fetched #{deliveries.size} deliveries, #{to_process.size} new")
74
+ if to_process.empty?
75
+ $stdout.puts('Nothing to do.')
76
+ return
77
+ end
78
+
79
+ replay_each(to_process, deliveries_path, endpoint_url, processed_file, interactive: interactive)
80
+ end
81
+
82
+ # Polls one or more webhooks on an interval and auto-replays new
83
+ # deliveries as they arrive. Resolves hook ids once up front, then
84
+ # loops forever until Ctrl-C.
85
+ #
86
+ # @param targets [Array<Hash>] one hash per hook, each with
87
+ # +:scope+ (:org / :repo), +:webhook_url+, and +:repo+ (for :repo scope)
88
+ # @param endpoint_url [String] local URL to POST replays to
89
+ # @param processed_file_for [#call] lambda taking a target hash,
90
+ # returns the state file path for that target
91
+ # @param interval [Integer] seconds between polls (default 15)
92
+ #
93
+ # @return [void]
94
+ #
95
+ def listen(targets:, endpoint_url:, processed_file_for:, interval: 30)
96
+ resolved = targets.map { |t| resolve_target(t, processed_file_for) }
97
+ $stdout.puts("Listening on #{resolved.size} hook(s) every #{interval}s. Ctrl-C to stop.")
98
+ resolved.each do |r|
99
+ $stdout.puts(" #{r[:scope]} hook #{r[:hook_id]} -> #{r[:webhook_url]}")
100
+ end
101
+
102
+ loop do
103
+ poll_all(resolved, endpoint_url)
104
+ $stdout.puts('=============')
105
+ $stdout.puts(' Sleeping...')
106
+ $stdout.puts('=============')
107
+ sleep(interval)
108
+ end
109
+ rescue Interrupt
110
+ $stdout.puts("\nStopped.")
111
+ end
112
+
113
+ # Resolves a listen target's hook id and precomputes its deliveries
114
+ # path + processed-file location.
115
+ #
116
+ # @return [Hash]
117
+ #
118
+ def resolve_target(target, processed_file_for)
119
+ scope = target.fetch(:scope).to_sym
120
+ repo = target[:repo]
121
+ webhook_url = target.fetch(:webhook_url)
122
+ hook_id = resolve_hook_id(scope: scope, repo: repo, webhook_url: webhook_url)
123
+
124
+ {
125
+ scope: scope,
126
+ repo: repo,
127
+ webhook_url: webhook_url,
128
+ hook_id: hook_id,
129
+ deliveries_path: "#{hooks_base_path(scope: scope, repo: repo)}/#{hook_id}/deliveries",
130
+ processed_file: processed_file_for.call(target),
131
+ }
132
+ end
133
+
134
+ # Collects unprocessed deliveries across every resolved target,
135
+ # merges them into a single list sorted by +delivered_at+, then
136
+ # replays them one-by-one in true chronological order. Transient
137
+ # API errors per target are logged and swallowed so the listen
138
+ # loop survives.
139
+ #
140
+ # @param resolved [Array<Hash>]
141
+ # @param endpoint_url [String]
142
+ #
143
+ # @return [void]
144
+ #
145
+ def poll_all(resolved, endpoint_url)
146
+ pending = []
147
+ resolved.each do |target|
148
+ processed = load_processed(target[:processed_file])
149
+ deliveries = gh_get(target[:deliveries_path], per_page: 50)
150
+ deliveries.each do |summary|
151
+ next if processed.include?(summary['id'].to_s)
152
+
153
+ pending << { target: target, summary: summary }
154
+ end
155
+ rescue Octokit::Error => e
156
+ warn("[#{target[:scope]}] poll failed: #{e.message}")
157
+ end
158
+
159
+ return if pending.empty?
160
+
161
+ pending.sort_by! { |item| item[:summary]['delivered_at'].to_s }
162
+ $stdout.puts("Replaying #{pending.size} delivery(ies) in delivered_at order")
163
+ replay_pending(pending, endpoint_url)
164
+ end
165
+
166
+ # Replays a pre-sorted, multi-target pending list one delivery at
167
+ # a time. Stops on the first non-success so state can be
168
+ # investigated without skipping ahead.
169
+ #
170
+ # @return [void]
171
+ #
172
+ def replay_pending(pending, endpoint_url)
173
+ pending.each.with_index(1) do |item, index|
174
+ target = item[:target]
175
+ summary = item[:summary]
176
+ id = summary['id']
177
+ delivery = gh_get("#{target[:deliveries_path]}/#{id}")
178
+ request_data = delivery.fetch('request')
179
+ github_delivery_id = request_data.dig('headers', 'X-GitHub-Delivery') || id
180
+
181
+ event = "#{summary['event']}.#{summary['action']}"
182
+ $stdout.puts(
183
+ "[#{index}/#{pending.size}] #{target[:scope]} #{id} #{event} " \
184
+ "@ #{summary['delivered_at']} - #{github_delivery_id}",
185
+ )
186
+
187
+ response = post(
188
+ headers: request_data.fetch('headers') || {},
189
+ payload: request_data.fetch('payload'),
190
+ endpoint_url: endpoint_url,
191
+ )
192
+
193
+ unless response.is_a?(Net::HTTPSuccess)
194
+ raise(' ! non-success; stopping so you can investigate')
195
+ end
196
+
197
+ mark_processed(target[:processed_file], id)
198
+ end
199
+ end
200
+
201
+ # @return [String]
202
+ def hooks_base_path(scope:, repo:)
203
+ case scope
204
+ when :org
205
+ "/orgs/#{PlanMyStuff.configuration.organization}/hooks"
206
+ when :repo
207
+ raise(ArgumentError, 'repo is required for :repo scope') if repo.nil? || repo.to_s.empty?
208
+
209
+ "/repos/#{repo}/hooks"
210
+ else
211
+ raise(ArgumentError, "Unknown scope #{scope.inspect}; expected :org or :repo")
212
+ end
213
+ end
214
+
215
+ # @return [Integer]
216
+ def resolve_hook_id(scope:, repo:, webhook_url:)
217
+ hooks = gh_get(hooks_base_path(scope: scope, repo: repo), per_page: 100)
218
+ match = hooks.find { |h| h.dig('config', 'url') == webhook_url }
219
+ return match['id'] if match.present?
220
+
221
+ available = hooks.map { |h| " id=#{h['id']} url=#{h.dig('config', 'url')}" }.join("\n")
222
+ raise(PlanMyStuff::Error, "No #{scope} webhook with config.url == #{webhook_url}. Visible:\n#{available}")
223
+ end
224
+
225
+ # Calls +octokit.get+ and returns the parsed JSON body (string-keyed
226
+ # Hash/Array) rather than Sawyer::Resource, so delivery payload and
227
+ # headers are easy to re-serialize.
228
+ #
229
+ # @return [Array, Hash]
230
+ #
231
+ def gh_get(path, **params)
232
+ client = PlanMyStuff.client.octokit
233
+ client.get(path, params)
234
+ JSON.parse(client.last_response.body)
235
+ end
236
+
237
+ # @return [Set<String>]
238
+ def load_processed(path)
239
+ return Set.new unless File.exist?(path)
240
+
241
+ File.readlines(path, chomp: true).reject(&:empty?).to_set
242
+ end
243
+
244
+ # @return [void]
245
+ def mark_processed(path, id)
246
+ FileUtils.mkdir_p(File.dirname(path))
247
+ File.open(path, 'a') { |f| f.puts(id) }
248
+ end
249
+
250
+ # @return [void]
251
+ def replay_each(to_process, deliveries_path, endpoint_url, processed_file, interactive:)
252
+ to_process.each.with_index(1) do |summary, index|
253
+ id = summary['id']
254
+ delivery = gh_get("#{deliveries_path}/#{id}")
255
+ request_data = delivery.fetch('request')
256
+ github_delivery_id = request_data.dig('headers', 'X-GitHub-Delivery') || id
257
+
258
+ event = "#{summary['event']}.#{summary['action']}"
259
+ $stdout.puts("[#{index}/#{to_process.size}] #{id} #{event} - #{github_delivery_id}")
260
+
261
+ response = post(
262
+ headers: request_data.fetch('headers') || {},
263
+ payload: request_data.fetch('payload'),
264
+ endpoint_url: endpoint_url,
265
+ )
266
+
267
+ unless response.is_a?(Net::HTTPSuccess)
268
+ raise(' ! non-success; stopping so you can investigate')
269
+ end
270
+
271
+ mark_processed(processed_file, id)
272
+ next unless interactive
273
+
274
+ $stdout.puts("Processed #{github_delivery_id} - Continue? (y/n)")
275
+ answer = $stdin.gets
276
+ break unless answer&.downcase&.start_with?('y')
277
+ end
278
+ end
279
+ end
280
+ end
data/lib/plan_my_stuff.rb CHANGED
@@ -4,7 +4,13 @@ require 'active_support/core_ext/array/wrap'
4
4
  require 'active_support/core_ext/object/blank'
5
5
 
6
6
  require_relative 'plan_my_stuff/application_record'
7
+ require_relative 'plan_my_stuff/approval'
8
+ require_relative 'plan_my_stuff/archive'
7
9
  require_relative 'plan_my_stuff/base_metadata'
10
+ require_relative 'plan_my_stuff/base_project'
11
+ require_relative 'plan_my_stuff/base_project_item'
12
+ require_relative 'plan_my_stuff/base_project_metadata'
13
+ require_relative 'plan_my_stuff/cache'
8
14
  require_relative 'plan_my_stuff/client'
9
15
  require_relative 'plan_my_stuff/comment'
10
16
  require_relative 'plan_my_stuff/comment_metadata'
@@ -12,15 +18,24 @@ require_relative 'plan_my_stuff/configuration'
12
18
  require_relative 'plan_my_stuff/custom_fields'
13
19
  require_relative 'plan_my_stuff/engine' if defined?(Rails)
14
20
  require_relative 'plan_my_stuff/errors'
21
+ require_relative 'plan_my_stuff/graphql/queries'
15
22
  require_relative 'plan_my_stuff/issue'
16
23
  require_relative 'plan_my_stuff/issue_metadata'
17
24
  require_relative 'plan_my_stuff/label'
25
+ require_relative 'plan_my_stuff/link'
18
26
  require_relative 'plan_my_stuff/markdown'
19
27
  require_relative 'plan_my_stuff/metadata_parser'
28
+ require_relative 'plan_my_stuff/notifications'
29
+ require_relative 'plan_my_stuff/pipeline'
20
30
  require_relative 'plan_my_stuff/project'
21
31
  require_relative 'plan_my_stuff/project_item'
32
+ require_relative 'plan_my_stuff/project_item_metadata'
22
33
  require_relative 'plan_my_stuff/project_metadata'
34
+ require_relative 'plan_my_stuff/reminders'
23
35
  require_relative 'plan_my_stuff/repo'
36
+ require_relative 'plan_my_stuff/testing_project'
37
+ require_relative 'plan_my_stuff/testing_project_item'
38
+ require_relative 'plan_my_stuff/testing_project_metadata'
24
39
  require_relative 'plan_my_stuff/user_resolver'
25
40
  require_relative 'plan_my_stuff/verifier'
26
41
  require_relative 'plan_my_stuff/version'
@@ -1,6 +1,185 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :plan_my_stuff do
4
+ desc 'Run the plan_my_stuff install generator (alias for `rails g plan_my_stuff:install`)'
5
+ task install: :environment do
6
+ require 'rails/generators'
7
+
8
+ require 'generators/plan_my_stuff/install/install_generator'
9
+ PlanMyStuff::Generators::InstallGenerator.start([])
10
+ end
11
+
12
+ desc 'Copy plan_my_stuff view templates (alias for `rails g plan_my_stuff:views`)'
13
+ task views: :environment do
14
+ require 'rails/generators'
15
+
16
+ require 'generators/plan_my_stuff/views/views_generator'
17
+ PlanMyStuff::Generators::ViewsGenerator.start([])
18
+ end
19
+
20
+ namespace :webhooks do
21
+ desc 'Create an organization webhook (URL=... [EVENTS=ev1,ev2])'
22
+ task create_org: :environment do
23
+ url = ENV.fetch('URL') { raise('URL env var is required') }
24
+ events = %w[projects_v2 projects_v2_item projects_v2_status_update]
25
+ config = PlanMyStuff.configuration
26
+ raise('PlanMyStuff.configuration.webhook_secret is blank') if config.webhook_secret.blank?
27
+
28
+ hook = PlanMyStuff.client.rest(
29
+ :create_org_hook,
30
+ config.organization,
31
+ { url: url, content_type: 'json', secret: config.webhook_secret, insecure_ssl: '0' },
32
+ { events: events, active: true },
33
+ )
34
+
35
+ puts("Created org hook #{hook.id} on #{config.organization} -> #{url}")
36
+ puts("Events: #{events.join(', ')}")
37
+ end
38
+
39
+ desc 'Create a repo webhook (REPO=owner/name URL=... [EVENTS=ev1,ev2])'
40
+ task create_repo: :environment do
41
+ url = ENV.fetch('URL') { raise('URL env var is required') }
42
+ repo = ENV.fetch('REPO') do
43
+ PlanMyStuff.client.resolve_repo ||
44
+ raise('REPO env var is required or configured (e.g. BrandsInsurance/PlanMyStuff)')
45
+ end
46
+ events = %w[pull_request issues]
47
+ config = PlanMyStuff.configuration
48
+ raise('PlanMyStuff.configuration.webhook_secret is blank') if config.webhook_secret.blank?
49
+
50
+ hook = PlanMyStuff.client.rest(
51
+ :create_hook,
52
+ repo,
53
+ 'web',
54
+ { url: url, content_type: 'json', secret: config.webhook_secret, insecure_ssl: '0' },
55
+ { events: events, active: true },
56
+ )
57
+
58
+ puts("Created repo hook #{hook.id} on #{repo} -> #{url}")
59
+ puts("Events: #{events.join(', ')}")
60
+ end
61
+
62
+ desc 'Replay recent deliveries to a local endpoint (ENDPOINT_URL=... WEBHOOK_URL=... ' \
63
+ '[SCOPE=org|repo] [REPO=owner/name] [PROCESSED_FILE=...] [NON_INTERACTIVE=1])'
64
+ task replay: :environment do
65
+ require 'plan_my_stuff/webhook_replayer'
66
+
67
+ endpoint_url = ENV.fetch('ENDPOINT_URL') { raise('ENDPOINT_URL env var is required') }
68
+ webhook_url = ENV.fetch('WEBHOOK_URL') { raise('WEBHOOK_URL env var is required') }
69
+ scope = ENV.fetch('SCOPE', 'org').to_sym
70
+ repo = ENV.fetch('REPO', nil)
71
+ processed_file =
72
+ ENV.fetch('PROCESSED_FILE') do
73
+ Rails.root.join('tmp', 'plan_my_stuff', "webhook_replay_#{scope}_processed.txt").to_s
74
+ end
75
+ interactive = ENV['NON_INTERACTIVE'].to_s.strip.empty?
76
+
77
+ PlanMyStuff::WebhookReplayer.fetch_and_replay(
78
+ endpoint_url: endpoint_url,
79
+ webhook_url: webhook_url,
80
+ scope: scope,
81
+ repo: repo,
82
+ processed_file: processed_file,
83
+ interactive: interactive,
84
+ )
85
+ end
86
+
87
+ desc 'Continuously poll org + repo hooks and auto-replay new deliveries ' \
88
+ '(ENDPOINT_URL=... [ORG_WEBHOOK_URL=...] [REPO_WEBHOOK_URL=... REPO=owner/name] [INTERVAL=15])'
89
+ task listen: :environment do
90
+ require 'plan_my_stuff/webhook_replayer'
91
+
92
+ endpoint_url = ENV.fetch('ENDPOINT_URL') { raise('ENDPOINT_URL env var is required') }
93
+ org_webhook_url = ENV.fetch('ORG_WEBHOOK_URL', nil)
94
+ repo_webhook_url = ENV.fetch('REPO_WEBHOOK_URL', nil)
95
+ repo = ENV.fetch('REPO', nil)
96
+ interval = ENV.fetch('INTERVAL', '30').to_i
97
+
98
+ targets = []
99
+ targets << { scope: :org, webhook_url: org_webhook_url } if org_webhook_url.present?
100
+ if repo_webhook_url.present?
101
+ raise('REPO env var required when REPO_WEBHOOK_URL is set') if repo.blank?
102
+
103
+ targets << { scope: :repo, webhook_url: repo_webhook_url, repo: repo }
104
+ end
105
+ raise('Set at least one of ORG_WEBHOOK_URL or REPO_WEBHOOK_URL') if targets.empty?
106
+
107
+ processed_file_for = -> (target) {
108
+ Rails.root.join('tmp', 'plan_my_stuff', "webhook_listen_#{target[:scope]}_processed.txt").to_s
109
+ }
110
+
111
+ PlanMyStuff::WebhookReplayer.listen(
112
+ targets: targets,
113
+ endpoint_url: endpoint_url,
114
+ processed_file_for: processed_file_for,
115
+ interval: interval,
116
+ )
117
+ end
118
+
119
+ desc 'Simulate an AWS SNS deployment webhook to a local endpoint ' \
120
+ '(ENDPOINT_URL=... [EVENT=SERVICE_DEPLOYMENT_COMPLETED])'
121
+ task simulate_aws: :environment do
122
+ require 'plan_my_stuff/aws_sns_simulator'
123
+
124
+ endpoint_url = ENV.fetch('ENDPOINT_URL') { raise('ENDPOINT_URL env var is required') }
125
+ event_name = ENV.fetch('EVENT', PlanMyStuff::AwsSnsSimulator::DEFAULT_EVENT)
126
+
127
+ PlanMyStuff::AwsSnsSimulator.post(
128
+ endpoint_url: endpoint_url,
129
+ event_name: event_name,
130
+ )
131
+ end
132
+ end
133
+
134
+ namespace :reminders do
135
+ desc 'Enqueue a RemindersSweepJob per configured repo ' \
136
+ '([REPO=<key>] to target a single repo)'
137
+ task sweep: :environment do
138
+ config = PlanMyStuff.configuration
139
+ repo_keys =
140
+ if ENV['REPO'].present?
141
+ [ENV['REPO'].to_sym]
142
+ else
143
+ config.repos.keys
144
+ end
145
+
146
+ raise('No repos configured (set config.repos or pass REPO=<key>)') if repo_keys.empty?
147
+
148
+ repo_keys.each do |key|
149
+ PlanMyStuff::RemindersSweepJob.requeue(key)
150
+ puts("Scheduled RemindersSweepJob for #{key.inspect} " \
151
+ "(#{config.repos[key] || '<unknown>'}) at #{PlanMyStuff::RemindersSweepJob.next_run.iso8601}")
152
+ end
153
+ end
154
+ end
155
+
156
+ namespace :testing do
157
+ desc 'Create a TestingProject template in the configured organization. ' \
158
+ 'Bootstraps all testing custom fields. Set the returned project number as ' \
159
+ 'config.testing_template_project_number, then configure its board view ' \
160
+ 'and "Column by: Test Status" manually in the GitHub UI. ' \
161
+ 'Options: TITLE="My Template" (default: "PMS Testing Template")'
162
+ task create_template: :environment do
163
+ title = ENV.fetch('TITLE', '[TEMPLATE] Testing Feature')
164
+
165
+ puts("Creating testing project template '#{title}' in " \
166
+ "#{PlanMyStuff.configuration.organization}...")
167
+
168
+ project = PlanMyStuff::TestingProject.create!(title: title)
169
+
170
+ puts
171
+ puts("\e[32mTemplate created successfully.\e[0m")
172
+ puts(" Title: #{project.title}")
173
+ puts(" Number: #{project.number}")
174
+ puts(" URL: #{project.url}")
175
+ puts
176
+ puts('Next steps:')
177
+ puts(' 1. Open the project in GitHub and set the board view to "Column by: Test Status"')
178
+ puts(' 2. Add to your PlanMyStuff initializer:')
179
+ puts(" config.testing_template_project_number = #{project.number}")
180
+ end
181
+ end
182
+
4
183
  desc 'Verify PlanMyStuff configuration: token, org, repos, and project access'
5
184
  task verify: :environment do
6
185
  require 'plan_my_stuff/verifier'
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.3.0
4
+ version: 0.5.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-06 00:00:00.000000000 Z
11
+ date: 2026-04-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -56,17 +56,34 @@ files:
56
56
  - README.md
57
57
  - app/controllers/plan_my_stuff/application_controller.rb
58
58
  - app/controllers/plan_my_stuff/comments_controller.rb
59
+ - app/controllers/plan_my_stuff/issues/approvals_controller.rb
60
+ - app/controllers/plan_my_stuff/issues/closures_controller.rb
61
+ - app/controllers/plan_my_stuff/issues/links_controller.rb
62
+ - app/controllers/plan_my_stuff/issues/takes_controller.rb
63
+ - app/controllers/plan_my_stuff/issues/viewers_controller.rb
64
+ - app/controllers/plan_my_stuff/issues/waitings_controller.rb
59
65
  - app/controllers/plan_my_stuff/issues_controller.rb
60
66
  - app/controllers/plan_my_stuff/labels_controller.rb
67
+ - app/controllers/plan_my_stuff/project_items/assignments_controller.rb
68
+ - app/controllers/plan_my_stuff/project_items/statuses_controller.rb
61
69
  - app/controllers/plan_my_stuff/project_items_controller.rb
62
70
  - app/controllers/plan_my_stuff/projects_controller.rb
71
+ - app/controllers/plan_my_stuff/testing_project_items/results_controller.rb
72
+ - app/controllers/plan_my_stuff/testing_project_items_controller.rb
73
+ - app/controllers/plan_my_stuff/testing_projects_controller.rb
74
+ - app/controllers/plan_my_stuff/webhooks/aws_controller.rb
75
+ - app/controllers/plan_my_stuff/webhooks/github_controller.rb
76
+ - app/jobs/plan_my_stuff/application_job.rb
77
+ - app/jobs/plan_my_stuff/reminders_sweep_job.rb
63
78
  - app/views/plan_my_stuff/comments/edit.html.erb
64
79
  - app/views/plan_my_stuff/comments/partials/_form.html.erb
65
80
  - app/views/plan_my_stuff/issues/edit.html.erb
66
81
  - app/views/plan_my_stuff/issues/index.html.erb
67
82
  - app/views/plan_my_stuff/issues/new.html.erb
83
+ - app/views/plan_my_stuff/issues/partials/_approvals.html.erb
68
84
  - app/views/plan_my_stuff/issues/partials/_form.html.erb
69
85
  - app/views/plan_my_stuff/issues/partials/_labels.html.erb
86
+ - app/views/plan_my_stuff/issues/partials/_links.html.erb
70
87
  - app/views/plan_my_stuff/issues/partials/_viewers.html.erb
71
88
  - app/views/plan_my_stuff/issues/show.html.erb
72
89
  - app/views/plan_my_stuff/projects/edit.html.erb
@@ -74,13 +91,29 @@ files:
74
91
  - app/views/plan_my_stuff/projects/new.html.erb
75
92
  - app/views/plan_my_stuff/projects/partials/_form.html.erb
76
93
  - app/views/plan_my_stuff/projects/show.html.erb
94
+ - app/views/plan_my_stuff/testing_project_items/new.html.erb
95
+ - app/views/plan_my_stuff/testing_project_items/results/new.html.erb
96
+ - app/views/plan_my_stuff/testing_projects/edit.html.erb
97
+ - app/views/plan_my_stuff/testing_projects/new.html.erb
98
+ - app/views/plan_my_stuff/testing_projects/partials/_form.html.erb
99
+ - app/views/plan_my_stuff/testing_projects/partials/_item.html.erb
100
+ - app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb
101
+ - app/views/plan_my_stuff/testing_projects/show.html.erb
77
102
  - config/routes.rb
78
103
  - lib/generators/plan_my_stuff/install/install_generator.rb
79
104
  - lib/generators/plan_my_stuff/install/templates/initializer.rb
80
105
  - lib/generators/plan_my_stuff/views/views_generator.rb
81
106
  - lib/plan_my_stuff.rb
82
107
  - lib/plan_my_stuff/application_record.rb
108
+ - lib/plan_my_stuff/approval.rb
109
+ - lib/plan_my_stuff/archive.rb
110
+ - lib/plan_my_stuff/archive/sweep.rb
111
+ - lib/plan_my_stuff/aws_sns_simulator.rb
83
112
  - lib/plan_my_stuff/base_metadata.rb
113
+ - lib/plan_my_stuff/base_project.rb
114
+ - lib/plan_my_stuff/base_project_item.rb
115
+ - lib/plan_my_stuff/base_project_metadata.rb
116
+ - lib/plan_my_stuff/cache.rb
84
117
  - lib/plan_my_stuff/client.rb
85
118
  - lib/plan_my_stuff/comment.rb
86
119
  - lib/plan_my_stuff/comment_metadata.rb
@@ -88,19 +121,34 @@ files:
88
121
  - lib/plan_my_stuff/custom_fields.rb
89
122
  - lib/plan_my_stuff/engine.rb
90
123
  - lib/plan_my_stuff/errors.rb
124
+ - lib/plan_my_stuff/graphql/queries.rb
91
125
  - lib/plan_my_stuff/issue.rb
92
126
  - lib/plan_my_stuff/issue_metadata.rb
93
127
  - lib/plan_my_stuff/label.rb
128
+ - lib/plan_my_stuff/link.rb
94
129
  - lib/plan_my_stuff/markdown.rb
95
130
  - lib/plan_my_stuff/metadata_parser.rb
131
+ - lib/plan_my_stuff/notifications.rb
132
+ - lib/plan_my_stuff/pipeline.rb
133
+ - lib/plan_my_stuff/pipeline/issue_linker.rb
134
+ - lib/plan_my_stuff/pipeline/status.rb
96
135
  - lib/plan_my_stuff/project.rb
97
136
  - lib/plan_my_stuff/project_item.rb
137
+ - lib/plan_my_stuff/project_item_metadata.rb
98
138
  - lib/plan_my_stuff/project_metadata.rb
139
+ - lib/plan_my_stuff/reminders.rb
140
+ - lib/plan_my_stuff/reminders/closer.rb
141
+ - lib/plan_my_stuff/reminders/fire.rb
142
+ - lib/plan_my_stuff/reminders/sweep.rb
99
143
  - lib/plan_my_stuff/repo.rb
100
144
  - lib/plan_my_stuff/test_helpers.rb
145
+ - lib/plan_my_stuff/testing_project.rb
146
+ - lib/plan_my_stuff/testing_project_item.rb
147
+ - lib/plan_my_stuff/testing_project_metadata.rb
101
148
  - lib/plan_my_stuff/user_resolver.rb
102
149
  - lib/plan_my_stuff/verifier.rb
103
150
  - lib/plan_my_stuff/version.rb
151
+ - lib/plan_my_stuff/webhook_replayer.rb
104
152
  - lib/tasks/plan_my_stuff.rake
105
153
  homepage: https://github.com/brandsinsurance/PlanMyStuff/
106
154
  licenses:
@@ -110,7 +158,33 @@ metadata:
110
158
  homepage_uri: https://github.com/brandsinsurance/PlanMyStuff/
111
159
  source_code_uri: https://github.com/brandsinsurance/PlanMyStuff
112
160
  changelog_uri: https://github.com/brandsinsurance/PlanMyStuff/blob/main/CHANGELOG.md
113
- post_install_message:
161
+ post_install_message: |
162
+ Thanks for installing plan_my_stuff!
163
+
164
+ Getting started:
165
+
166
+ rake plan_my_stuff:install
167
+ Generates config/initializers/plan_my_stuff.rb and mounts the engine
168
+
169
+ rake plan_my_stuff:views
170
+ Copies view templates to app/views/plan_my_stuff/ (optional)
171
+
172
+ Available rake tasks:
173
+
174
+ plan_my_stuff:verify
175
+ Verify token, org, repos, and project access
176
+
177
+ plan_my_stuff:webhooks:create_org URL=... [EVENTS=...]
178
+ plan_my_stuff:webhooks:create_repo URL=... [REPO=owner/name] [EVENTS=...]
179
+ plan_my_stuff:webhooks:replay ENDPOINT_URL=... WEBHOOK_URL=... [SCOPE=org|repo]
180
+ plan_my_stuff:webhooks:listen ENDPOINT_URL=... [ORG_WEBHOOK_URL=...] [REPO_WEBHOOK_URL=... REPO=...]
181
+ plan_my_stuff:webhooks:simulate_aws ENDPOINT_URL=... [EVENT=...]
182
+
183
+ plan_my_stuff:reminders:sweep [REPO=<key>]
184
+
185
+ plan_my_stuff:testing:create_template [TITLE=...]
186
+
187
+ Run `rake -D plan_my_stuff` for full descriptions.
114
188
  rdoc_options: []
115
189
  require_paths:
116
190
  - lib