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
data/README.md CHANGED
@@ -9,15 +9,6 @@ A Rails engine gem that provides a GitHub-backed ticketing and project tracking
9
9
 
10
10
  **Ruby:** >= 3.3 | **Rails:** >= 6.1, < 8 | **API client:** Octokit
11
11
 
12
- > [!IMPORTANT]
13
- > **commonmarker version compatibility:** This gem requires commonmarker v1.0+ (pinned `~> 2.7`). The v0.23 to v1.0 upgrade was a full rewrite with breaking changes:
14
- > - Module renamed: `CommonMarker` to `Commonmarker`
15
- > - Methods renamed: `render_html` to `to_html`, `render_doc` to `parse`
16
- > - Options changed from array of symbols (`[:HARDBREAKS]`) to nested hash (`options: { render: { hardbreaks: true } }`)
17
- > - Underlying engine changed from C (libcmark-gfm) to Rust (comrak)
18
- >
19
- > If your app already uses commonmarker < 1.0, you will need to upgrade. Alternatively, configure `markdown_renderer = :redcarpet` or `nil` to avoid the dependency entirely.
20
-
21
12
  ## Installation
22
13
 
23
14
  Add to your Gemfile:
@@ -51,6 +42,9 @@ The gem supports three markdown rendering options. The chosen gem must be in you
51
42
  | redcarpet | `gem 'redcarpet'` | `config.markdown_renderer = :redcarpet` |
52
43
  | None (raw) | Nothing | `config.markdown_renderer = nil` |
53
44
 
45
+ > [!NOTE]
46
+ > If you pick `:commonmarker`, use v1.0+ — the v0.23 → v1.0 rewrite renamed the module to `Commonmarker`, swapped `render_html` for `to_html`, and moved options from a symbol array to a nested hash. Apps still on commonmarker < 1.0 must upgrade or switch to `:redcarpet` / `nil`.
47
+
54
48
  ### Overriding views
55
49
 
56
50
  Copy the default views into your app for customization:
@@ -65,7 +59,7 @@ rails generate plan_my_stuff:views
65
59
  # config/initializers/plan_my_stuff.rb
66
60
  PlanMyStuff.configure do |config|
67
61
  # Auth (PAT from a bot account with repo + project scopes)
68
- config.access_token = ENV['PMS_GITHUB_TOKEN']
62
+ config.access_token = Rails.application.credentials.dig(:plan_my_stuff, :github_token)
69
63
 
70
64
  # Organization
71
65
  config.organization = 'YourOrganization'
@@ -99,10 +93,8 @@ PlanMyStuff.configure do |config|
99
93
  update_status: 'PmsUpdateStatusJob'
100
94
  }
101
95
 
102
- # Deferred request notifications
103
- config.deferred_notifier = nil
104
- config.deferred_email_from = 'noreply@example.com'
105
- config.deferred_email_to = 'support@example.com'
96
+ # Fallback actor for notification events when a caller does not pass user:
97
+ config.current_user = -> { Current.user }
106
98
 
107
99
  # Custom fields (stored in issue/comment metadata)
108
100
  config.custom_fields = {
@@ -117,6 +109,41 @@ end
117
109
 
118
110
  The `PMS` alias is available for brevity: `PMS.configure`, `PMS::Issue.find`, etc.
119
111
 
112
+ ## Architecture
113
+
114
+ All state lives on GitHub. The gem exposes it through ActiveRecord-style domain objects that call the GitHub REST and GraphQL APIs under the hood.
115
+
116
+ ### Domain class hierarchy
117
+
118
+ ```
119
+ ApplicationRecord # includes ActiveModel::Model, Attributes, Serializers::JSON
120
+ ├── Issue # GitHub Issue
121
+ ├── Comment # GitHub Issue comment
122
+ ├── Label # GitHub Label
123
+ ├── BaseProject # shared GraphQL hydration for Projects V2
124
+ │ ├── Project # regular project board (metadata.kind == "project")
125
+ │ └── TestingProject # testing/QA board (metadata.kind == "testing")
126
+ └── BaseProjectItem # shared item behaviour
127
+ ├── ProjectItem
128
+ └── TestingProjectItem # adds pass/fail sign-off logic
129
+ ```
130
+
131
+ ### AR-style API
132
+
133
+ Every domain class exposes the same surface:
134
+
135
+ | Class method | Instance method | Notes |
136
+ |---|---|---|
137
+ | `.find(id, ...)` | `#reload`, `#raise_if_stale!` | single read |
138
+ | `.list(...)` | — | list read |
139
+ | `.create!(attrs)` | — | write + fires `*.created` event |
140
+ | — | `#save!`, `#update!(attrs)` | write + fires `*.updated` event |
141
+ | — | `#persisted?`, `#new_record?`, `#destroyed?` | state predicates |
142
+
143
+ Issues and comments are never hard-deleted (GitHub keeps them forever) — `Issue#update!(state: :closed)` and the archive workflow handle lifecycle instead. `ProjectItem#destroy!` and `TestingProjectItem#destroy!` remove items from a board.
144
+
145
+ Every mutating method accepts `user:` so notifications know the actor; falls back to `config.current_user.call` if omitted.
146
+
120
147
  ## Usage
121
148
 
122
149
  ### Issues
@@ -152,8 +179,8 @@ issue.update!(state: :closed)
152
179
  issue.update!(state: :open)
153
180
 
154
181
  # Viewer management (visibility allowlist)
155
- PMS::Issue.add_viewers(repo: :cms_website, number: 123, user_ids: [5, 12])
156
- PMS::Issue.remove_viewers(repo: :cms_website, number: 123, user_ids: [5])
182
+ issue.add_viewers(user_ids: [5, 12], user: current_user)
183
+ issue.remove_viewers(user_ids: [5], user: current_user)
157
184
  ```
158
185
 
159
186
  ### Comments
@@ -212,15 +239,465 @@ PMS::Project.add_item(project_number: 14, issue_repo: :cms_website, issue_number
212
239
  PMS::Project.add_draft_item(project_number: 14, title: 'Draft task', body: 'Details...')
213
240
  ```
214
241
 
242
+ ### Testing tracking
243
+
244
+ `TestingProject` and `TestingProjectItem` are a specialisation of the project classes for manual QA sign-off. A testing project is a GitHub Projects V2 board stamped with `metadata.kind == "testing"` and a fixed set of single-select and text fields (Test Status, Testers, Watchers, Pass Mode, Due Date, Deadline Miss Reason, Result Notes, Passed By, Failed By, Passed At).
245
+
246
+ Create a board — either bootstrap fields from scratch, or clone the board configured at `config.testing_template_project_number`:
247
+
248
+ ```ruby
249
+ project = PMS::TestingProject.create!(
250
+ title: 'Release 2026.05 manual QA',
251
+ user: current_user,
252
+ subject_urls: ['https://github.com/Org/Repo/pull/421'],
253
+ due_date: Date.parse('2026-05-10'),
254
+ )
255
+ ```
256
+
257
+ Per-item sign-off:
258
+
259
+ ```ruby
260
+ item = project.items.first
261
+ item.update_pass_mode!('all') # or 'any'
262
+ item.update_testers!(user_ids: [alice.id, bob.id])
263
+ item.update_watchers!(user_ids: [carol.id])
264
+ item.mark_passed!(user: alice) # flips to Passed when Pass Mode is satisfied
265
+ item.mark_failed!(user: alice, result_notes: 'Reproduced on Safari 17')
266
+ ```
267
+
268
+ A board and its items are editable through the mounted UI at `/testing_projects`.
269
+
270
+ ## Notifications
271
+
272
+ Every PMS lifecycle write fires an `ActiveSupport::Notifications` event under the `plan_my_stuff.*` namespace. Subscribe to drive email, webhooks, Slack, ActiveJob, etc. Events fire synchronously (no delays) — subscribers that need to defer work should wrap in a background job themselves.
273
+
274
+ ### Event catalog
275
+
276
+ | Event | Fired from |
277
+ |-------|------------|
278
+ | `plan_my_stuff.issue.created` | `Issue.create!` |
279
+ | `plan_my_stuff.issue.updated` | `issue.save!` / `issue.update!` (any non-state change) |
280
+ | `plan_my_stuff.issue.closed` | `issue.update!(state: :closed)` |
281
+ | `plan_my_stuff.issue.reopened` | `issue.update!(state: :open)` |
282
+ | `plan_my_stuff.issue.viewers_added` | `issue.add_viewers` |
283
+ | `plan_my_stuff.issue.viewers_removed` | `issue.remove_viewers` |
284
+ | `plan_my_stuff.issue.approval_requested` | `issue.request_approvals!` |
285
+ | `plan_my_stuff.issue.approval_granted` | `issue.approve!` |
286
+ | `plan_my_stuff.issue.approval_revoked` | `issue.revoke_approval!` |
287
+ | `plan_my_stuff.issue.all_approved` | aggregate — fires when the set flips to fully approved |
288
+ | `plan_my_stuff.issue.approvals_invalidated` | aggregate — fires when a revoke (or new approver) drops the set out of fully-approved |
289
+ | `plan_my_stuff.comment.created` | `Comment.create!` |
290
+ | `plan_my_stuff.comment.updated` | `comment.save!` / `comment.update!` |
291
+ | `plan_my_stuff.label.added` | `Label.add` |
292
+ | `plan_my_stuff.label.removed` | `Label.remove` |
293
+ | `plan_my_stuff.project_item.added` | `ProjectItem.create!` / `.add_draft_item` |
294
+ | `plan_my_stuff.project_item.removed` | `project_item.destroy!` |
295
+ | `plan_my_stuff.project_item.assigned` | `project_item.assign!` |
296
+ | `plan_my_stuff.project_item.status_changed` | `project_item.move_to!` |
297
+
298
+ ### Payload
299
+
300
+ All events include:
301
+
302
+ - `:<resource>` — the domain object (`:issue`, `:comment`, `:project_item`)
303
+ - `:user` — the actor (see below)
304
+ - `:timestamp` — `Time.current`
305
+ - `:visibility` and `:visibility_allowlist` — present for `Issue` and `Comment` events
306
+
307
+ Additional keys by event:
308
+
309
+ - `issue.updated` / `comment.updated` → `:changes` — hash of `{ attr => [old, new] }`
310
+ - `issue.viewers_added` / `viewers_removed` → `:user_ids`
311
+ - `issue.approval_requested` → `:approvals` (array of newly-added `PMS::Approval`)
312
+ - `issue.approval_granted` / `approval_revoked` → `:approval` (the flipped `PMS::Approval`)
313
+ - `issue.approvals_invalidated` → `:trigger` — `:revoked` or `:approver_added`
314
+ - `label.added` / `label.removed` → `:labels`
315
+ - `project_item.assigned` → `:assignees`
316
+ - `project_item.status_changed` → `:status`, `:previous_status`
317
+
318
+ ### Actor resolution
319
+
320
+ The `:user` payload key is populated in this precedence:
321
+
322
+ 1. `user:` kwarg passed to the mutating method (e.g. `issue.update!(title: 'x', user: alice)`)
323
+ 2. `config.current_user` proc/lambda, if set (called at event time)
324
+ 3. `nil`
325
+
326
+ Use `config.current_user = -> { Current.user }` so request-scoped code doesn't have to thread `user:` everywhere.
327
+
328
+ ### Subscribing
329
+
330
+ ```ruby
331
+ # config/initializers/plan_my_stuff_subscribers.rb
332
+ ActiveSupport::Notifications.subscribe(/^plan_my_stuff\./) do |name, _start, _finish, _id, payload|
333
+ PmsEventMailer.dispatch(name, payload).deliver_later
334
+ end
335
+ ```
336
+
337
+ ### Testing subscribers
338
+
339
+ ```ruby
340
+ require 'plan_my_stuff/test_helpers'
341
+
342
+ # Block-style matcher
343
+ expect {
344
+ PMS::Issue.create!(title: 'Bug', body: 'Desc', user: alice)
345
+ }.to(have_fired_event('plan_my_stuff.issue.created').with(user: alice))
346
+
347
+ # Raw capture
348
+ events = PlanMyStuff::TestHelpers::Notifications.capture do
349
+ PMS::Issue.create!(...)
350
+ end
351
+ events.first[:name] # => "plan_my_stuff.issue.created"
352
+ events.first[:payload] # => { issue:, user:, timestamp:, ... }
353
+ ```
354
+
355
+ ## Approvals
356
+
357
+ Manager-approval workflow on issues. Support users request approvals from one or more users; while any approval is pending, forward `PMS::Pipeline` transitions raise `PMS::PendingApprovalsError`. State is stored as a hidden array on `IssueMetadata.approvals` — no extra tables, no GitHub-side schema.
358
+
359
+ ### Writing
360
+
361
+ ```ruby
362
+ issue = PMS::Issue.find(123, repo: :cms_website)
363
+
364
+ # Support adds required approvers (idempotent; rejects duplicates silently)
365
+ issue.request_approvals!(user_ids: [alice.id, bob.id], user: current_user)
366
+
367
+ # An approver grants their own approval
368
+ issue.approve!(user: alice)
369
+
370
+ # An approver revokes their own approval; support may revoke on another's behalf
371
+ issue.revoke_approval!(user: alice)
372
+ issue.revoke_approval!(user: support_user, target_user_id: alice.id)
373
+
374
+ # Support removes approvers
375
+ issue.remove_approvers!(user_ids: [alice.id], user: current_user)
376
+ ```
377
+
378
+ ### Reading
379
+
380
+ ```ruby
381
+ issue.approvers # Array<PMS::Approval> — all records (pending + approved)
382
+ issue.pending_approvals # subset still pending
383
+ issue.approvals_required? # true iff approvers.any?
384
+ issue.fully_approved? # true iff approvals_required? && pending_approvals.empty?
385
+ ```
386
+
387
+ ### Pipeline gating
388
+
389
+ `PMS::Pipeline::PendingApprovalsError` is raised when any pending approval exists on the linked issue. Gated transitions:
390
+
391
+ - `Pipeline.submit!`
392
+ - `Pipeline.take!`
393
+ - `Pipeline.mark_in_review!`
394
+ - `Pipeline.request_testing!`
395
+ - `Pipeline.mark_ready_for_release!`
396
+
397
+ `Pipeline.remove!`, `start_deployment!`, and `complete_deployment!` are intentionally NOT gated (reverse / batch / CI-driven transitions).
398
+
399
+ ### Events
400
+
401
+ See the notifications catalog above — `approval_requested`, `approval_granted`, `approval_revoked`, `all_approved`, `approvals_invalidated`. Aggregate events fire after the matching granular event on the same write.
402
+
403
+ ### Testing
404
+
405
+ ```ruby
406
+ require 'plan_my_stuff/test_helpers'
407
+
408
+ # Build an issue with some approvals already in place (no API call)
409
+ issue = PlanMyStuff::TestHelpers.build_issue
410
+ PlanMyStuff::TestHelpers.stub_approvals(issue, approved: [alice], pending: [bob])
411
+
412
+ issue.fully_approved? # false
413
+ issue.pending_approvals # [PMS::Approval(user_id: bob.id, status: 'pending')]
414
+ ```
415
+
416
+ ## Reminders
417
+
418
+ Follow-up reminders for issues waiting on an end-user reply or pending approvers. A daily sweep fires `plan_my_stuff.issue.reminder_due` events on waiting issues whose next milestone has passed; issues that exceed the inactivity ceiling are auto-closed.
419
+
420
+ ### Waiting on user
421
+
422
+ A support user enters an issue into "waiting on user" state by either:
423
+
424
+ 1. Posting a comment with `waiting_on_reply: true`:
425
+
426
+ ```ruby
427
+ PMS::Comment.create!(
428
+ issue: issue,
429
+ body: "Can you confirm the date range?",
430
+ user: current_user,
431
+ waiting_on_reply: true,
432
+ )
433
+ ```
434
+
435
+ 2. Clicking the **Mark waiting** button on the mounted issue show page.
436
+
437
+ Both paths add the `waiting-on-user` label, set `issue.metadata.waiting_on_user_at = now`, and schedule the first reminder.
438
+
439
+ When an end-user posts a comment through the gem, the label is removed and the waiting state clears. If the issue had been auto-closed for inactivity, the reply also reopens it and fires `plan_my_stuff.issue.reopened_by_reply`.
440
+
441
+ ### Waiting on approval
442
+
443
+ Whenever the pending-approval count goes from zero to one (via `issue.request_approvals!` or `issue.revoke_approval!`), the gem adds the `waiting-on-approval` label and sets `issue.metadata.waiting_on_approval_at = now`. When all pending approvers respond or are removed, the label and timestamp clear automatically.
444
+
445
+ ### Reminder schedule
446
+
447
+ Reminders fire at specific day counts since waiting started:
448
+
449
+ ```ruby
450
+ config.reminder_days = [1, 3, 7, 10, 14, 18] # default
451
+ ```
452
+
453
+ Override per-issue:
454
+
455
+ ```ruby
456
+ issue.metadata.reminder_days = [2, 5, 9]
457
+ issue.save!
458
+ ```
459
+
460
+ Each reminder emits `plan_my_stuff.issue.reminder_due` with payload:
461
+
462
+ | Field | Type | Notes |
463
+ |---|---|---|
464
+ | `issue` | `PMS::Issue` | the waiting issue |
465
+ | `waiting_kind` | `:user` or `:approval` | which clock drove the reminder |
466
+ | `days_waiting` | Integer | days since the waiting clock started |
467
+ | `reminder_day` | Integer | the matched entry in `reminder_days` |
468
+ | `last_activity_at` | Time | last comment or `issue.updated_at` (informational) |
469
+ | `pending_approvers` | `Array<User>` | resolved via `UserResolver`; only for `:approval` |
470
+
471
+ Subscribe in your app to deliver (email, Slack, etc.):
472
+
473
+ ```ruby
474
+ ActiveSupport::Notifications.subscribe('plan_my_stuff.issue.reminder_due') do |event|
475
+ issue = event.payload[:issue]
476
+ case event.payload[:waiting_kind]
477
+ when :user then NotifyUserMailer.reminder(issue).deliver_later
478
+ when :approval then NotifyApproversMailer.reminder(issue, event.payload[:pending_approvers]).deliver_later
479
+ end
480
+ end
481
+ ```
482
+
483
+ ### Inactivity auto-close
484
+
485
+ When an issue's `days_waiting` reaches `config.inactivity_close_days` (default 30), the sweep:
486
+
487
+ - Closes the issue
488
+ - Sets `issue.metadata.closed_by_inactivity = true`
489
+ - Adds the `user-inactive` label (configurable via `config.user_inactive_label`)
490
+ - Emits `plan_my_stuff.issue.closed_inactive` (with `reason: :inactivity`) — the regular `issue.closed` event is suppressed so subscribers listening to "close" don't double-fire for automated closes
491
+
492
+ An end-user reply on a `closed_by_inactivity` issue auto-reopens it, strips the `user-inactive` label, and fires `plan_my_stuff.issue.reopened_by_reply`.
493
+
494
+ ### Scheduling the sweep
495
+
496
+ The gem ships `PlanMyStuff::RemindersSweepJob`, an ActiveJob that walks a repo's waiting issues and dispatches reminders + auto-closes. Each job self-requeues after perform (default cadence: 6:30am ET next day), so you only need to kick off the initial enqueue — a rake task handles that:
497
+
498
+ ```bash
499
+ # Enqueue one job per configured repo:
500
+ rake plan_my_stuff:reminders:sweep
501
+
502
+ # Or target a single repo:
503
+ rake plan_my_stuff:reminders:sweep REPO=element
504
+ ```
505
+
506
+ You can also schedule from Ruby if you prefer:
507
+
508
+ ```ruby
509
+ PlanMyStuff::RemindersSweepJob.requeue(:your_repo_key) # schedules for next_run
510
+ ```
511
+
512
+ `retry_on StandardError, attempts: 1` prevents geometric duplicate pile-up on Delayed-style adapters — if perform raises, the follow-up run (already enqueued by the `around_perform` ensure) picks up tomorrow.
513
+
514
+ Override the cadence by subclassing:
515
+
516
+ ```ruby
517
+ class MyRemindersJob < PlanMyStuff::RemindersSweepJob
518
+ def self.next_run
519
+ 4.hours.from_now.utc # run every 4 hours
520
+ end
521
+ end
522
+ ```
523
+
524
+ ### Disabling
525
+
526
+ ```ruby
527
+ config.reminders_enabled = false
528
+ ```
529
+
530
+ The sweep becomes a no-op but still self-requeues so re-enabling doesn't require manual intervention.
531
+
532
+ ## Auto-archiving
533
+
534
+ Closed PMS issues that have aged past `config.archive_closed_after_days` (default 90) are archived by the daily sweep. Archive means:
535
+
536
+ 1. Tag the issue with `config.archived_label` (default `archived`).
537
+ 2. Lock the conversation on GitHub (no new comments).
538
+ 3. Remove the issue from every Projects V2 board it sits on.
539
+ 4. Stamp `metadata.archived_at`.
540
+ 5. Emit `plan_my_stuff.issue.archived` with `reason: :aged_closed`.
541
+
542
+ The sweep runs inside `PlanMyStuff::RemindersSweepJob` alongside the follow-up reminders sweep — same cadence, same rake task.
543
+
544
+ ### Exclusions
545
+
546
+ - **Non-PMS issues** (no `pms-metadata` marker) are skipped — the gem only archives what it created.
547
+ - **Inactivity-closed issues** (`metadata.closed_by_inactivity == true`) are skipped — `T-051` already handled the close.
548
+ - **Already-archived issues** (`metadata.archived_at` set or archived label present) are skipped.
549
+
550
+ ### Manual archive
551
+
552
+ ```ruby
553
+ issue = PMS::Issue.find(123)
554
+ issue.archive!
555
+ ```
556
+
557
+ No-op if the issue is open, already archived, or already carries the archived label.
558
+
559
+ ### Config
560
+
561
+ ```ruby
562
+ config.archiving_enabled = true
563
+ config.archive_closed_after_days = 90
564
+ config.archived_label = 'archived'
565
+ ```
566
+
567
+ Locked issues reject new comments through the gem: `PMS::Comment.create!` raises `PMS::LockedIssueError` when `issue.locked?` is true. The mounted comments controller catches this and redirects back to the issue show with an error flash.
568
+
569
+ ### Subscribing
570
+
571
+ ```ruby
572
+ ActiveSupport::Notifications.subscribe('plan_my_stuff.issue.archived') do |event|
573
+ issue = event.payload[:issue]
574
+ ArchiveMailer.notify(issue).deliver_later
575
+ end
576
+ ```
577
+
578
+ ### Disabling
579
+
580
+ ```ruby
581
+ config.archiving_enabled = false
582
+ ```
583
+
584
+ Reminders keep running; only the archive pass is suppressed.
585
+
586
+ ## Caching
587
+
588
+ Read-heavy endpoints cache through `Rails.cache` with HTTP ETags. When the cache has a stored ETag, the gem sends it as `If-None-Match`; GitHub replies 304 and the cached payload is reused (one HTTP round-trip, zero quota spend on the "primary rate limit").
589
+
590
+ | Cached | Method |
591
+ |---|---|
592
+ | `Issue.find`, `Issue.list` | ETag |
593
+ | `Comment.find`, `Comment.list` | ETag |
594
+ | `Project.find`, `ProjectItem` reads | not cached (GraphQL, no HTTP ETag — deferred) |
595
+
596
+ Writes front-load the cache: `Issue.create!` / `Issue#update!` / `Label.add` / `Label.remove` / `Comment.create!` / `Comment#update!` all write the fresh payload (and bust affected list caches) so the next read is already warm. Label changes bust the parent issue's cache since labels arrive embedded in the issue payload.
597
+
598
+ ### Configuration
599
+
600
+ ```ruby
601
+ config.cache_enabled = true # default; set false to bypass entirely
602
+ config.cache_version = 'v2' # opaque string baked into every cache key;
603
+ # bump to orphan all cached entries after deploy
604
+ ```
605
+
606
+ Cache keys include both the gem's `CACHE_VERSION` ("v1") and `config.cache_version`, so gem upgrades and app-side invalidations are independent.
607
+
215
608
  ## Metadata
216
609
 
217
- All state lives on GitHub. Issue and comment metadata is stored as a hidden HTML comment on the first line of the body:
610
+ All state lives on GitHub. Each domain class carries a typed metadata object hidden in the corresponding GitHub body (issue/comment body, project readme) as an HTML comment that's invisible when rendered on github.com:
218
611
 
219
612
  ```html
220
- <!-- pms-metadata:{"gem_version":"0.0.0","app_name":"MyApp","created_at":"2026-03-24T10:00:00Z",...} -->
613
+ <!-- pms-metadata:{"schema_version":1,"gem_version":"0.0.0","app_name":"MyApp",...} -->
614
+ ```
615
+
616
+ `MetadataParser` strips the block out of the raw body on read and re-inserts it on write — callers see `issue.body` (human text) and `issue.metadata` (typed object) separately.
617
+
618
+ ### Metadata classes
619
+
620
+ | Class | Stored on | Notable fields |
621
+ |---|---|---|
622
+ | `IssueMetadata` | Issue body | `approvals`, `links`, `waiting_on_user_at`, `waiting_on_approval_at`, `next_reminder_at`, `visibility_allowlist`, `commit_sha`, `auto_complete`, `archived_at`, `closed_by_inactivity`, `custom_fields` |
623
+ | `CommentMetadata` | Comment body | `issue_body` (marks the issue's body-comment), `custom_fields` |
624
+ | `ProjectMetadata` | Project readme | `kind: "project"` |
625
+ | `TestingProjectMetadata` | Testing project readme | `kind: "testing"`, `subject_urls`, `due_date`, `deadline_miss_reason` |
626
+ | `ProjectItemMetadata` | — | minimal; reserved for future fields |
627
+
628
+ All metadata classes share `schema_version`, `gem_version`, `app_name`, `created_by`, `visibility`, and `custom_fields` via `BaseMetadata`.
629
+
630
+ ### Custom fields
631
+
632
+ `custom_fields` is the escape hatch for app-specific data. Declare a schema in the initializer:
633
+
634
+ ```ruby
635
+ PlanMyStuff.configure do |config|
636
+ # Shared across everything that has custom_fields
637
+ config.custom_fields = {
638
+ ticket_type: { type: :string, required: true },
639
+ }
640
+
641
+ # Resource-specific (merged on top of shared)
642
+ config.issue_custom_fields = { notification_recipients: { type: :array } }
643
+ config.comment_custom_fields = { internal_note: { type: :boolean } }
644
+ config.project_custom_fields = { team: { type: :string } }
645
+ config.testing_custom_fields = { test_plan_url: { type: :string } }
646
+ end
647
+ ```
648
+
649
+ Access by name or hash:
650
+
651
+ ```ruby
652
+ issue.metadata.custom_fields.ticket_type # method-style
653
+ issue.metadata.custom_fields[:ticket_type] = 'bug'
654
+ issue.save! # validates; raises ActiveModel::ValidationError
655
+ ```
656
+
657
+ ## Release pipeline
658
+
659
+ `PMS::Pipeline` is a seven-stage state machine that lives on a GitHub Projects V2 board — your code drives transitions, webhooks automate them, and each step fires a notification event.
660
+
661
+ ### Statuses
662
+
663
+ `PMS::Pipeline::Status::ALL` (in order): `Submitted` → `Started` → `In Review` → `Testing` → `Ready for Release` → `Release in Progress` → `Completed`.
664
+
665
+ Display names can be overridden in the consuming app via `config.pipeline_statuses`; the constants above remain the internal identifiers.
666
+
667
+ ### Transitions
668
+
669
+ ```ruby
670
+ # Add an issue to the pipeline board
671
+ item = PMS::Pipeline.submit!(issue, assignee: current_user.github_login)
672
+
673
+ # Forward transitions
674
+ PMS::Pipeline.take!(item) # Started
675
+ PMS::Pipeline.mark_in_review!(item) # In Review
676
+ PMS::Pipeline.request_testing!(item) # Testing
677
+ PMS::Pipeline.mark_ready_for_release!(item) # Ready for Release
678
+
679
+ # Deployment-driven transitions
680
+ PMS::Pipeline.start_deployment!(commit_sha: 'abc123…') # every Ready item whose issue is in the commit → Release in Progress
681
+ PMS::Pipeline.complete_deployment!(item, deployment_id: 42) # Completed (when issue.metadata.auto_complete)
682
+
683
+ # Remove from pipeline (deletes the project item)
684
+ PMS::Pipeline.remove!(item)
221
685
  ```
222
686
 
223
- This is invisible when rendered on GitHub and parsed automatically by the gem into typed objects (`PMS::IssueMetadata`, `PMS::CommentMetadata`).
687
+ Forward transitions call `Pipeline.guard_approvals!(issue)`, which raises `PMS::Pipeline::PendingApprovalsError` if the linked issue has un-approved required approvers. Batch/automated transitions (`start_deployment!`, `complete_deployment!`, `remove!`) skip the guard on purpose.
688
+
689
+ ### Webhook-driven automation
690
+
691
+ The engine mounts two webhook endpoints when the pipeline is enabled:
692
+
693
+ - `POST /webhooks/github` — takes GitHub `pull_request` events. On `closed` against a tracked branch, the gem calls `IssueLinker` to extract `#123` references from the PR body and commit messages, then transitions each linked issue (e.g. merged to main → `start_deployment!`).
694
+ - `POST /webhooks/aws` — takes CodeDeploy/SNS lifecycle events. On a successful deployment, the gem flips matching items to `Completed`.
695
+
696
+ See `designs/release_cycle/plan.md` for the full design, including the `projects_v2_item` path that fires `Pipeline.take!` when a human drags an item to Started on github.com.
697
+
698
+ ### "Take" button
699
+
700
+ The mounted UI exposes a **Take** button on pipeline issues that calls `Pipeline.take!` and assigns the current user. Your app's user-id → GitHub login mapping is provided by `config.github_login_for` (a hash keyed by user id).
224
701
 
225
702
  ## Verify setup
226
703
 
@@ -259,26 +736,80 @@ expect_pms_item_moved(status: 'In Review')
259
736
 
260
737
  ## Engine routes
261
738
 
262
- The engine provides these routes (all under the configured mount point):
739
+ All routes are under the engine's mount point. Each group can be disabled via `config.mount_groups = { issues: true, projects: true, webhooks: true }`; pipeline-only routes (`/issues/:id/take`, `/webhooks/*`) are also gated on `config.pipeline_enabled`. See `config/routes.rb` for the source of truth.
740
+
741
+ ### Issues
263
742
 
264
743
  | Route | Action |
265
744
  |---|---|
266
- | `GET /` | Issue index |
267
- | `GET /issues/new` | New issue form |
268
- | `POST /issues` | Create issue |
269
- | `GET /issues/:id` | Issue detail with comments |
270
- | `GET /issues/:id/edit` | Edit issue form |
271
- | `PATCH /issues/:id` | Update issue |
272
- | `PATCH /issues/:id/close` | Close issue |
273
- | `PATCH /issues/:id/reopen` | Reopen issue |
274
- | `POST /issues/:id/comments` | Create comment |
275
- | `GET /issues/:id/comments/:id/edit` | Edit comment |
276
- | `PATCH /issues/:id/comments/:id` | Update comment |
277
- | `POST /issues/:id/labels` | Add label |
278
- | `DELETE /issues/:id/labels/:name` | Remove label |
279
- | `GET /projects` | Project list |
280
- | `GET /projects/:id` | Project board |
281
- | `POST /projects/:id/items` | Add item to project |
282
- | `PATCH /projects/:id/items/:id/move` | Move item |
283
- | `PATCH /projects/:id/items/:id/assign` | Assign item |
745
+ | `GET /issues`, `GET /issues/new`, `POST /issues` | index, new, create |
746
+ | `GET /issues/:id`, `GET /issues/:id/edit`, `PATCH /issues/:id` | show, edit, update |
747
+ | `POST /issues/:issue_id/closure` / `DELETE …/closure` | close / reopen |
748
+ | `POST /issues/:issue_id/waiting` / `DELETE …/waiting` | mark / clear waiting-on-user |
749
+ | `POST /issues/:issue_id/viewers` / `DELETE …/viewers/:id` | add / remove viewer |
750
+ | `POST /issues/:issue_id/take` (pipeline) | Take button → `Pipeline.take!` |
751
+ | `POST /issues/:issue_id/comments`, `GET …/comments/:id/edit`, `PATCH …/comments/:id` | create / edit / update comment |
752
+ | `POST /issues/:issue_id/labels` / `DELETE …/labels/:id` | add / remove label |
753
+ | `POST /issues/:issue_id/links` / `DELETE …/links/:id` | link / unlink related issue |
754
+ | `POST /issues/:issue_id/approvals`, `PATCH …/approvals/:id`, `DELETE …/approvals/:id` | request / grant or revoke / remove approver |
755
+
756
+ ### Projects
757
+
758
+ | Route | Action |
759
+ |---|---|
760
+ | `GET /projects`, `GET /projects/new`, `POST /projects` | index, new, create |
761
+ | `GET /projects/:id`, `GET /projects/:id/edit`, `PATCH /projects/:id` | show, edit, update |
762
+ | `POST /projects/:project_id/items` | add item |
763
+ | `PATCH /projects/:project_id/items/:item_id/status` | move item to a status column |
764
+ | `PATCH /projects/:project_id/items/:item_id/assignment` / `DELETE …/assignment` | assign / unassign |
765
+
766
+ ### Testing projects
767
+
768
+ | Route | Action |
769
+ |---|---|
770
+ | `GET /testing_projects/new`, `POST /testing_projects` | new, create |
771
+ | `GET /testing_projects/:id`, `GET …/:id/edit`, `PATCH …/:id` | show, edit, update |
772
+ | `GET /testing_projects/:testing_project_id/items/new`, `POST …/items` | new, create item |
773
+ | `PATCH /testing_projects/:testing_project_id/items/:item_id/status` | move item |
774
+ | `GET …/items/:item_id/result/new`, `POST …/items/:item_id/result` | record pass/fail result |
775
+
776
+ ### Webhooks (pipeline only)
777
+
778
+ | Route | Action |
779
+ |---|---|
780
+ | `POST /webhooks/github` | GitHub PR / projects_v2_item events |
781
+ | `POST /webhooks/aws` | AWS CodeDeploy / SNS lifecycle events |
782
+
783
+ ## Controller overrides
784
+
785
+ Every mounted route resolves its controller through `config.controller_for(key)`, which looks up `config.controllers[key]` and falls back to the gem default. Subclass a gem controller in your own app and register it to wedge in before_actions, authentication, or response tweaks — no monkey patching.
786
+
787
+ ```ruby
788
+ # app/controllers/my_app/issues_controller.rb
789
+ class MyApp::IssuesController < PlanMyStuff::IssuesController
790
+ before_action :authenticate_user!
791
+ before_action :authorize_ticket_access
792
+ end
793
+ ```
794
+
795
+ ```ruby
796
+ # config/initializers/plan_my_stuff.rb
797
+ PlanMyStuff.configure do |config|
798
+ config.controllers['issues'] = 'my_app/issues'
799
+ end
800
+ ```
801
+
802
+ Overridable keys (see `PlanMyStuff::Configuration::DEFAULT_CONTROLLERS`):
803
+
804
+ ```
805
+ issues issues/closures
806
+ comments issues/viewers
807
+ labels issues/takes
808
+ projects issues/waitings
809
+ project_items issues/links
810
+ testing_projects issues/approvals
811
+ testing_project_items project_items/statuses
812
+ webhooks/github project_items/assignments
813
+ webhooks/aws testing_project_items/results
814
+ ```
284
815