plan_my_stuff 0.2.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 (87) 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 +65 -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/edit.html.erb +7 -0
  31. data/app/views/plan_my_stuff/projects/index.html.erb +17 -1
  32. data/app/views/plan_my_stuff/projects/new.html.erb +7 -0
  33. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +29 -0
  34. data/app/views/plan_my_stuff/projects/show.html.erb +11 -4
  35. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  36. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  37. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  38. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  39. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  40. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  41. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  42. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  43. data/config/routes.rb +38 -15
  44. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  45. data/lib/plan_my_stuff/application_record.rb +144 -0
  46. data/lib/plan_my_stuff/approval.rb +80 -0
  47. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  48. data/lib/plan_my_stuff/archive.rb +14 -0
  49. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  50. data/lib/plan_my_stuff/base_metadata.rb +0 -11
  51. data/lib/plan_my_stuff/base_project.rb +661 -0
  52. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  53. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  54. data/lib/plan_my_stuff/cache.rb +197 -0
  55. data/lib/plan_my_stuff/client.rb +7 -0
  56. data/lib/plan_my_stuff/comment.rb +174 -54
  57. data/lib/plan_my_stuff/configuration.rb +254 -8
  58. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  59. data/lib/plan_my_stuff/engine.rb +0 -4
  60. data/lib/plan_my_stuff/errors.rb +49 -0
  61. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  62. data/lib/plan_my_stuff/issue.rb +1477 -174
  63. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  64. data/lib/plan_my_stuff/label.rb +82 -11
  65. data/lib/plan_my_stuff/link.rb +144 -0
  66. data/lib/plan_my_stuff/notifications.rb +142 -0
  67. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  68. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  69. data/lib/plan_my_stuff/pipeline.rb +293 -0
  70. data/lib/plan_my_stuff/project.rb +62 -468
  71. data/lib/plan_my_stuff/project_item.rb +3 -417
  72. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  73. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  74. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  75. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  76. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  77. data/lib/plan_my_stuff/reminders.rb +16 -0
  78. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  79. data/lib/plan_my_stuff/testing_project.rb +291 -0
  80. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  81. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  82. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  83. data/lib/plan_my_stuff/version.rb +1 -1
  84. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  85. data/lib/plan_my_stuff.rb +16 -0
  86. data/lib/tasks/plan_my_stuff.rake +163 -0
  87. metadata +54 -2
@@ -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,14 +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'
33
+ require_relative 'plan_my_stuff/project_metadata'
34
+ require_relative 'plan_my_stuff/reminders'
22
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'
23
39
  require_relative 'plan_my_stuff/user_resolver'
24
40
  require_relative 'plan_my_stuff/verifier'
25
41
  require_relative 'plan_my_stuff/version'
@@ -1,6 +1,169 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :plan_my_stuff do
4
+ namespace :webhooks do
5
+ desc 'Create an organization webhook (URL=... [EVENTS=ev1,ev2])'
6
+ task create_org: :environment do
7
+ url = ENV.fetch('URL') { raise('URL env var is required') }
8
+ events = %w[projects_v2 projects_v2_item projects_v2_status_update]
9
+ config = PlanMyStuff.configuration
10
+ raise('PlanMyStuff.configuration.webhook_secret is blank') if config.webhook_secret.blank?
11
+
12
+ hook = PlanMyStuff.client.rest(
13
+ :create_org_hook,
14
+ config.organization,
15
+ { url: url, content_type: 'json', secret: config.webhook_secret, insecure_ssl: '0' },
16
+ { events: events, active: true },
17
+ )
18
+
19
+ puts("Created org hook #{hook.id} on #{config.organization} -> #{url}")
20
+ puts("Events: #{events.join(', ')}")
21
+ end
22
+
23
+ desc 'Create a repo webhook (REPO=owner/name URL=... [EVENTS=ev1,ev2])'
24
+ task create_repo: :environment do
25
+ url = ENV.fetch('URL') { raise('URL env var is required') }
26
+ repo = ENV.fetch('REPO') do
27
+ PlanMyStuff.client.resolve_repo ||
28
+ raise('REPO env var is required or configured (e.g. BrandsInsurance/PlanMyStuff)')
29
+ end
30
+ events = %w[pull_request issues]
31
+ config = PlanMyStuff.configuration
32
+ raise('PlanMyStuff.configuration.webhook_secret is blank') if config.webhook_secret.blank?
33
+
34
+ hook = PlanMyStuff.client.rest(
35
+ :create_hook,
36
+ repo,
37
+ 'web',
38
+ { url: url, content_type: 'json', secret: config.webhook_secret, insecure_ssl: '0' },
39
+ { events: events, active: true },
40
+ )
41
+
42
+ puts("Created repo hook #{hook.id} on #{repo} -> #{url}")
43
+ puts("Events: #{events.join(', ')}")
44
+ end
45
+
46
+ desc 'Replay recent deliveries to a local endpoint (ENDPOINT_URL=... WEBHOOK_URL=... ' \
47
+ '[SCOPE=org|repo] [REPO=owner/name] [PROCESSED_FILE=...] [NON_INTERACTIVE=1])'
48
+ task replay: :environment do
49
+ require 'plan_my_stuff/webhook_replayer'
50
+
51
+ endpoint_url = ENV.fetch('ENDPOINT_URL') { raise('ENDPOINT_URL env var is required') }
52
+ webhook_url = ENV.fetch('WEBHOOK_URL') { raise('WEBHOOK_URL env var is required') }
53
+ scope = ENV.fetch('SCOPE', 'org').to_sym
54
+ repo = ENV.fetch('REPO', nil)
55
+ processed_file =
56
+ ENV.fetch('PROCESSED_FILE') do
57
+ Rails.root.join('tmp', 'plan_my_stuff', "webhook_replay_#{scope}_processed.txt").to_s
58
+ end
59
+ interactive = ENV['NON_INTERACTIVE'].to_s.strip.empty?
60
+
61
+ PlanMyStuff::WebhookReplayer.fetch_and_replay(
62
+ endpoint_url: endpoint_url,
63
+ webhook_url: webhook_url,
64
+ scope: scope,
65
+ repo: repo,
66
+ processed_file: processed_file,
67
+ interactive: interactive,
68
+ )
69
+ end
70
+
71
+ desc 'Continuously poll org + repo hooks and auto-replay new deliveries ' \
72
+ '(ENDPOINT_URL=... [ORG_WEBHOOK_URL=...] [REPO_WEBHOOK_URL=... REPO=owner/name] [INTERVAL=15])'
73
+ task listen: :environment do
74
+ require 'plan_my_stuff/webhook_replayer'
75
+
76
+ endpoint_url = ENV.fetch('ENDPOINT_URL') { raise('ENDPOINT_URL env var is required') }
77
+ org_webhook_url = ENV.fetch('ORG_WEBHOOK_URL', nil)
78
+ repo_webhook_url = ENV.fetch('REPO_WEBHOOK_URL', nil)
79
+ repo = ENV.fetch('REPO', nil)
80
+ interval = ENV.fetch('INTERVAL', '30').to_i
81
+
82
+ targets = []
83
+ targets << { scope: :org, webhook_url: org_webhook_url } if org_webhook_url.present?
84
+ if repo_webhook_url.present?
85
+ raise('REPO env var required when REPO_WEBHOOK_URL is set') if repo.blank?
86
+
87
+ targets << { scope: :repo, webhook_url: repo_webhook_url, repo: repo }
88
+ end
89
+ raise('Set at least one of ORG_WEBHOOK_URL or REPO_WEBHOOK_URL') if targets.empty?
90
+
91
+ processed_file_for = -> (target) {
92
+ Rails.root.join('tmp', 'plan_my_stuff', "webhook_listen_#{target[:scope]}_processed.txt").to_s
93
+ }
94
+
95
+ PlanMyStuff::WebhookReplayer.listen(
96
+ targets: targets,
97
+ endpoint_url: endpoint_url,
98
+ processed_file_for: processed_file_for,
99
+ interval: interval,
100
+ )
101
+ end
102
+
103
+ desc 'Simulate an AWS SNS deployment webhook to a local endpoint ' \
104
+ '(ENDPOINT_URL=... [EVENT=SERVICE_DEPLOYMENT_COMPLETED])'
105
+ task simulate_aws: :environment do
106
+ require 'plan_my_stuff/aws_sns_simulator'
107
+
108
+ endpoint_url = ENV.fetch('ENDPOINT_URL') { raise('ENDPOINT_URL env var is required') }
109
+ event_name = ENV.fetch('EVENT', PlanMyStuff::AwsSnsSimulator::DEFAULT_EVENT)
110
+
111
+ PlanMyStuff::AwsSnsSimulator.post(
112
+ endpoint_url: endpoint_url,
113
+ event_name: event_name,
114
+ )
115
+ end
116
+ end
117
+
118
+ namespace :reminders do
119
+ desc 'Enqueue a RemindersSweepJob per configured repo ' \
120
+ '([REPO=<key>] to target a single repo)'
121
+ task sweep: :environment do
122
+ config = PlanMyStuff.configuration
123
+ repo_keys =
124
+ if ENV['REPO'].present?
125
+ [ENV['REPO'].to_sym]
126
+ else
127
+ config.repos.keys
128
+ end
129
+
130
+ raise('No repos configured (set config.repos or pass REPO=<key>)') if repo_keys.empty?
131
+
132
+ repo_keys.each do |key|
133
+ PlanMyStuff::RemindersSweepJob.requeue(key)
134
+ puts("Scheduled RemindersSweepJob for #{key.inspect} " \
135
+ "(#{config.repos[key] || '<unknown>'}) at #{PlanMyStuff::RemindersSweepJob.next_run.iso8601}")
136
+ end
137
+ end
138
+ end
139
+
140
+ namespace :testing do
141
+ desc 'Create a TestingProject template in the configured organization. ' \
142
+ 'Bootstraps all testing custom fields. Set the returned project number as ' \
143
+ 'config.testing_template_project_number, then configure its board view ' \
144
+ 'and "Column by: Test Status" manually in the GitHub UI. ' \
145
+ 'Options: TITLE="My Template" (default: "PMS Testing Template")'
146
+ task create_template: :environment do
147
+ title = ENV.fetch('TITLE', '[TEMPLATE] Testing Feature')
148
+
149
+ puts("Creating testing project template '#{title}' in " \
150
+ "#{PlanMyStuff.configuration.organization}...")
151
+
152
+ project = PlanMyStuff::TestingProject.create!(title: title)
153
+
154
+ puts
155
+ puts("\e[32mTemplate created successfully.\e[0m")
156
+ puts(" Title: #{project.title}")
157
+ puts(" Number: #{project.number}")
158
+ puts(" URL: #{project.url}")
159
+ puts
160
+ puts('Next steps:')
161
+ puts(' 1. Open the project in GitHub and set the board view to "Column by: Test Status"')
162
+ puts(' 2. Add to your PlanMyStuff initializer:')
163
+ puts(" config.testing_template_project_number = #{project.number}")
164
+ end
165
+ end
166
+
4
167
  desc 'Verify PlanMyStuff configuration: token, org, repos, and project access'
5
168
  task verify: :environment do
6
169
  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.2.0
4
+ version: 0.4.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,28 +56,64 @@ 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
89
+ - app/views/plan_my_stuff/projects/edit.html.erb
72
90
  - app/views/plan_my_stuff/projects/index.html.erb
91
+ - app/views/plan_my_stuff/projects/new.html.erb
92
+ - app/views/plan_my_stuff/projects/partials/_form.html.erb
73
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
74
102
  - config/routes.rb
75
103
  - lib/generators/plan_my_stuff/install/install_generator.rb
76
104
  - lib/generators/plan_my_stuff/install/templates/initializer.rb
77
105
  - lib/generators/plan_my_stuff/views/views_generator.rb
78
106
  - lib/plan_my_stuff.rb
79
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
80
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
81
117
  - lib/plan_my_stuff/client.rb
82
118
  - lib/plan_my_stuff/comment.rb
83
119
  - lib/plan_my_stuff/comment_metadata.rb
@@ -85,18 +121,34 @@ files:
85
121
  - lib/plan_my_stuff/custom_fields.rb
86
122
  - lib/plan_my_stuff/engine.rb
87
123
  - lib/plan_my_stuff/errors.rb
124
+ - lib/plan_my_stuff/graphql/queries.rb
88
125
  - lib/plan_my_stuff/issue.rb
89
126
  - lib/plan_my_stuff/issue_metadata.rb
90
127
  - lib/plan_my_stuff/label.rb
128
+ - lib/plan_my_stuff/link.rb
91
129
  - lib/plan_my_stuff/markdown.rb
92
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
93
135
  - lib/plan_my_stuff/project.rb
94
136
  - lib/plan_my_stuff/project_item.rb
137
+ - lib/plan_my_stuff/project_item_metadata.rb
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
95
143
  - lib/plan_my_stuff/repo.rb
96
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
97
148
  - lib/plan_my_stuff/user_resolver.rb
98
149
  - lib/plan_my_stuff/verifier.rb
99
150
  - lib/plan_my_stuff/version.rb
151
+ - lib/plan_my_stuff/webhook_replayer.rb
100
152
  - lib/tasks/plan_my_stuff.rake
101
153
  homepage: https://github.com/brandsinsurance/PlanMyStuff/
102
154
  licenses: