plan_my_stuff 0.1.0 → 1.0.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. metadata +99 -4
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:
@@ -61,61 +55,57 @@ rails generate plan_my_stuff:views
61
55
 
62
56
  ## Configuration
63
57
 
64
- ```ruby
65
- # config/initializers/plan_my_stuff.rb
66
- PlanMyStuff.configure do |config|
67
- # Auth (PAT from a bot account with repo + project scopes)
68
- config.access_token = ENV['PMS_GITHUB_TOKEN']
58
+ The install generator drops a fully-commented initializer at
59
+ `config/initializers/plan_my_stuff.rb`. Two options are required:
69
60
 
70
- # Organization
61
+ ```ruby
62
+ PMS.configure do |config|
63
+ config.access_token = Rails.application.credentials.dig(:plan_my_stuff, :github_token)
71
64
  config.organization = 'YourOrganization'
65
+ end
66
+ ```
72
67
 
73
- # Named repo configs
74
- config.repos[:marketing_website] = 'YourOrganization/MarketingWebsite'
75
- config.repos[:cms_website] = 'YourOrganization/CMSWebsite'
76
- config.default_repo = :cms_website
68
+ Everything else is optional. See [CONFIGURATION.md](CONFIGURATION.md) for the
69
+ full option reference grouped by concern (repos, projects, user
70
+ integration, markdown, request gateway, custom fields, pipeline,
71
+ reminders, archiving, AWS webhook, caching, routes, controllers).
77
72
 
78
- # Default project board
79
- config.default_project_number = 123
73
+ The `PMS` alias is available for brevity: `PMS.configure`, `PMS::Issue.find`, etc.
80
74
 
81
- # User class (your app's model)
82
- config.user_class = 'User'
83
- config.display_name_method = :full_name
84
- config.user_id_method = :id
75
+ ## Architecture
85
76
 
86
- # Support role check (symbol or proc)
87
- config.support_method = :support?
77
+ 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.
88
78
 
89
- # Markdown rendering (:commonmarker, :redcarpet, or nil)
90
- config.markdown_renderer = :commonmarker
79
+ ### Domain class hierarchy
91
80
 
92
- # Request gateway (proc or nil; nil = always send)
93
- config.should_send_request = nil
81
+ ```text
82
+ ApplicationRecord # includes ActiveModel::Model, Attributes, Serializers::JSON
83
+ ├── Issue # GitHub Issue
84
+ ├── Comment # GitHub Issue comment
85
+ ├── Label # GitHub Label
86
+ ├── BaseProject # shared GraphQL hydration for Projects V2
87
+ │ ├── Project # regular project board (metadata.kind == "project")
88
+ │ └── TestingProject # testing/QA board (metadata.kind == "testing")
89
+ └── BaseProjectItem # shared item behaviour
90
+ ├── ProjectItem
91
+ └── TestingProjectItem # adds pass/fail sign-off logic
92
+ ```
94
93
 
95
- # Background jobs (when gateway defers a request)
96
- config.job_classes = {
97
- create_ticket: 'PmsCreateTicketJob',
98
- post_comment: 'PmsPostCommentJob',
99
- update_status: 'PmsUpdateStatusJob'
100
- }
94
+ ### AR-style API
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
+ Every domain class exposes the same surface:
106
97
 
107
- # Custom fields (stored in issue/comment metadata)
108
- config.custom_fields = {
109
- ticket_type: { type: :string },
110
- notification_recipients: { type: :array }
111
- }
98
+ | Class method | Instance method | Notes |
99
+ |---|---|---|
100
+ | `.find(id, ...)` | `#reload`, `#raise_if_stale!` | single read |
101
+ | `.list(...)` | | list read |
102
+ | `.create!(attrs)` | — | write + fires `*.created` event |
103
+ | — | `#save!`, `#update!(attrs)` | write + fires `*.updated` event |
104
+ | — | `#persisted?`, `#new_record?`, `#destroyed?` | state predicates |
112
105
 
113
- # App name (appears in metadata)
114
- config.app_name = 'MyApp'
115
- end
116
- ```
106
+ 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.
117
107
 
118
- The `PMS` alias is available for brevity: `PMS.configure`, `PMS::Issue.find`, etc.
108
+ Every mutating method accepts `user:` so notifications know the actor; falls back to `config.current_user.call` if omitted.
119
109
 
120
110
  ## Usage
121
111
 
@@ -133,10 +123,10 @@ issue = PMS::Issue.create!(
133
123
  )
134
124
 
135
125
  # Find
136
- issue = PMS::Issue.find(repo: :cms_website, number: 123)
126
+ issue = PMS::Issue.find(123, repo: :cms_website)
137
127
  issue.title
138
128
  issue.body # body without metadata
139
- issue.metadata # PlanMyStuff::IssueMetadata
129
+ issue.metadata # PMS::IssueMetadata
140
130
  issue.visible_to?(user) # visibility check
141
131
  issue.comments # all comments
142
132
  issue.pms_comments # only PMS-created comments
@@ -152,28 +142,63 @@ issue.update!(state: :closed)
152
142
  issue.update!(state: :open)
153
143
 
154
144
  # 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])
145
+ issue.add_viewers!(user_ids: [5, 12], user: current_user)
146
+ issue.remove_viewers!(user_ids: [5], user: current_user)
147
+ ```
148
+
149
+ #### Importing existing issues
150
+
151
+ `PMS::Issue.import!` wraps GitHub's "Import Issues" preview endpoint. It takes an array of GitHub-shaped payloads (one POST per payload) and returns one status hash per input. Payloads are passed through unchanged except for `:repo`, which is extracted to build the request URL.
152
+
153
+ > [!CAUTION]
154
+ > If you choose to use `PMS::Issue.import!`/`PMS::Issue.check_import!`, you must configure `import_access_token`
155
+
156
+ ```ruby
157
+ payloads = [
158
+ {
159
+ repo: :cms_website,
160
+ issue: {
161
+ title: '[Rawr-12517] Delete contractor check payment',
162
+ body: "Imported from YouTrack...",
163
+ labels: ['imported-from-youtrack', 'priority:normal'],
164
+ created_at: '2026-04-28T16:04:59Z',
165
+ },
166
+ comments: [
167
+ { body: 'first comment', created_at: '2026-04-28T16:26:08Z' },
168
+ { body: 'second comment', created_at: '2026-04-28T16:26:29Z' },
169
+ ],
170
+ },
171
+ ]
172
+
173
+ statuses = PMS::Issue.import!(payloads)
174
+ status = PMS::Issue.check_import!(statuses.first[:id], repo: :cms_website)
175
+ # => { id: ..., status: 'imported', issue_url: '.../issues/123' }
157
176
  ```
158
177
 
178
+ Notes:
179
+
180
+ - Each payload MUST include `:repo` (symbol, string, or `PMS::Repo`); a missing `:repo` raises `ArgumentError`.
181
+ - The gem does not embed PMS metadata into the payload — callers are responsible for any metadata or formatting they want to preserve from the source system.
182
+ - The endpoint is async: `import` returns `{ id:, status: 'pending', url: ... }`; poll `check_import` until `status` is `'imported'` or `'failed'`.
183
+ - Requires a classic GitHub PAT with the `repo` scope; fine-grained tokens are rejected with 403.
184
+
159
185
  ### Comments
160
186
 
161
187
  ```ruby
162
188
  # Create
163
189
  comment = PMS::Comment.create!(
164
- repo: :cms_website,
165
- issue_number: 123,
190
+ issue: issue,
166
191
  body: 'Deployed fix to staging, please retest.',
167
192
  user: current_user,
168
193
  visibility: :public # or :internal (support-only)
169
194
  )
170
195
 
171
196
  # List
172
- comments = PMS::Comment.list(repo: :cms_website, issue_number: 123)
173
- comments = PMS::Comment.list(repo: :cms_website, issue_number: 123, pms_only: true)
197
+ comments = PMS::Comment.list(issue: issue)
198
+ comments = PMS::Comment.list(issue: issue, pms_only: true)
174
199
 
175
200
  comment.body # visible text
176
- comment.metadata # PlanMyStuff::CommentMetadata (nil for non-PMS comments)
201
+ comment.metadata # PMS::CommentMetadata (nil for non-PMS comments)
177
202
  comment.visibility # :public, :internal, or nil
178
203
  comment.pms_comment? # true/false
179
204
  comment.visible_to?(user)
@@ -182,8 +207,8 @@ comment.visible_to?(user)
182
207
  ### Labels
183
208
 
184
209
  ```ruby
185
- PMS::Label.add(repo: :cms_website, issue_number: 123, labels: ['in-progress'])
186
- PMS::Label.remove(repo: :cms_website, issue_number: 123, labels: ['triage'])
210
+ PMS::Label.add!(issue: issue, labels: ['in-progress'])
211
+ PMS::Label.remove!(issue: issue, labels: ['triage'])
187
212
  ```
188
213
 
189
214
  ### Projects
@@ -206,21 +231,494 @@ item.move_to!('In Review')
206
231
  item.assign!('octocat')
207
232
 
208
233
  # Add existing issue to project
209
- PMS::Project.add_item(project_number: 14, issue_repo: :cms_website, issue_number: 123)
234
+ PMS::ProjectItem.create!(issue, project_number: 14)
210
235
 
211
236
  # Add draft item
212
- PMS::Project.add_draft_item(project_number: 14, title: 'Draft task', body: 'Details...')
237
+ PMS::ProjectItem.create!('Draft task', draft: true, body: 'Details...', project_number: 14)
238
+ ```
239
+
240
+ ### Testing tracking
241
+
242
+ `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).
243
+
244
+ Create a board — either bootstrap fields from scratch, or clone the board configured at `config.testing_template_project_number`:
245
+
246
+ ```ruby
247
+ project = PMS::TestingProject.create!(
248
+ title: 'Release 2026.05 manual QA',
249
+ user: current_user,
250
+ subject_urls: ['https://github.com/Org/Repo/pull/421'],
251
+ due_date: Date.parse('2026-05-10'),
252
+ )
253
+ ```
254
+
255
+ Per-item sign-off:
256
+
257
+ ```ruby
258
+ item = project.items.first
259
+ item.update_pass_mode!('all') # or 'any'
260
+ item.update_testers!([alice.id, bob.id])
261
+ item.update_watchers!([carol.id])
262
+ item.mark_passed!(alice) # flips to Passed when Pass Mode is satisfied
263
+ item.mark_failed!(alice, result_notes: 'Reproduced on Safari 17')
264
+ ```
265
+
266
+ A board and its items are editable through the mounted UI at `/testing_projects`.
267
+
268
+ ## Notifications
269
+
270
+ Every PMS lifecycle write fires an `ActiveSupport::Notifications` event under the `*.plan_my_stuff` namespace
271
+ (Rails convention - event first, library suffix; matches `sql.active_record`, `process_action.action_controller`).
272
+ Subscribe to drive email, webhooks, Slack, ActiveJob, etc. Events fire synchronously (no delays) - subscribers
273
+ that need to defer work should wrap in a background job themselves.
274
+
275
+ ### Event catalog
276
+
277
+ | Event | Fired from |
278
+ |-------|------------|
279
+ | `issue_created.plan_my_stuff` | `Issue.create!` |
280
+ | `issue_updated.plan_my_stuff` | `issue.save!` / `issue.update!` (any non-state change) |
281
+ | `issue_closed.plan_my_stuff` | `issue.update!(state: :closed)` |
282
+ | `issue_reopened.plan_my_stuff` | `issue.update!(state: :open)` |
283
+ | `issue_viewers_added.plan_my_stuff` | `issue.add_viewers!` |
284
+ | `issue_viewers_removed.plan_my_stuff` | `issue.remove_viewers!` |
285
+ | `issue_approval_requested.plan_my_stuff` | `issue.request_approvals!` |
286
+ | `issue_approval_granted.plan_my_stuff` | `issue.approve!` |
287
+ | `issue_approval_revoked.plan_my_stuff` | `issue.revoke_approval!` |
288
+ | `issue_all_approved.plan_my_stuff` | aggregate; fires when the set flips to fully approved |
289
+ | `issue_approvals_invalidated.plan_my_stuff` | aggregate; fires on revoke or new approver dropping the set |
290
+ | `comment_created.plan_my_stuff` | `Comment.create!` |
291
+ | `comment_updated.plan_my_stuff` | `comment.save!` / `comment.update!` |
292
+ | `label_added.plan_my_stuff` | `Label.add!` |
293
+ | `label_removed.plan_my_stuff` | `Label.remove!` |
294
+ | `project_item_added.plan_my_stuff` | `ProjectItem.create!` (issue or `draft: true`) |
295
+ | `project_item_removed.plan_my_stuff` | `project_item.destroy!` |
296
+ | `project_item_assigned.plan_my_stuff` | `project_item.assign!` |
297
+ | `project_item_status_changed.plan_my_stuff` | `project_item.move_to!` |
298
+
299
+ ### Payload
300
+
301
+ All events include:
302
+
303
+ - `:<resource>` — the domain object (`:issue`, `:comment`, `:project_item`)
304
+ - `:user` — the actor (see below)
305
+ - `:timestamp` — `Time.current`
306
+ - `:visibility` and `:visibility_allowlist` — present for `Issue` and `Comment` events
307
+
308
+ Additional keys by event:
309
+
310
+ - `issue_updated` / `comment_updated` -> `:changes` -- hash of `{ attr => [old, new] }`
311
+ - `issue_viewers_added` / `issue_viewers_removed` -> `:user_ids`
312
+ - `issue_approval_requested` -> `:approvals` (array of newly-added `PMS::Approval`)
313
+ - `issue_approval_granted` / `issue_approval_revoked` -> `:approval` (the flipped `PMS::Approval`)
314
+ - `issue_approvals_invalidated` -> `:trigger` -- `:revoked` or `:approver_added`
315
+ - `label_added` / `label_removed` -> `:labels`
316
+ - `project_item_assigned` -> `:assignees`
317
+ - `project_item_status_changed` -> `:status`, `:previous_status`
318
+
319
+ ### Actor resolution
320
+
321
+ The `:user` payload key is populated in this precedence:
322
+
323
+ 1. `user:` kwarg passed to the mutating method (e.g. `issue.update!(title: 'x', user: alice)`)
324
+ 2. `config.current_user` proc/lambda, if set (called at event time)
325
+ 3. `nil`
326
+
327
+ Use `config.current_user = -> { Current.user }` so request-scoped code doesn't have to thread `user:` everywhere.
328
+
329
+ ### Subscribing
330
+
331
+ ```ruby
332
+ # config/initializers/plan_my_stuff_subscribers.rb
333
+ ActiveSupport::Notifications.subscribe(/\.plan_my_stuff\z/) do |name, _start, _finish, _id, payload|
334
+ PmsEventMailer.dispatch(name, payload).deliver_later
335
+ end
336
+ ```
337
+
338
+ ### Testing subscribers
339
+
340
+ ```ruby
341
+ require 'plan_my_stuff/test_helpers'
342
+
343
+ # Block-style matcher
344
+ expect {
345
+ PMS::Issue.create!(title: 'Bug', body: 'Desc', user: alice)
346
+ }.to(have_fired_event('issue_created.plan_my_stuff').with(user: alice))
347
+
348
+ # Raw capture
349
+ events = PMS::TestHelpers::Notifications.capture do
350
+ PMS::Issue.create!(...)
351
+ end
352
+ events.first[:name] # => "issue_created.plan_my_stuff"
353
+ events.first[:payload] # => { issue:, user:, timestamp:, ... }
354
+ ```
355
+
356
+ ## Approvals
357
+
358
+ 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.
359
+
360
+ ### Writing
361
+
362
+ ```ruby
363
+ issue = PMS::Issue.find(123, repo: :cms_website)
364
+
365
+ # Support adds required approvers (idempotent; rejects duplicates silently)
366
+ issue.request_approvals!(user_ids: [alice.id, bob.id], user: current_user)
367
+
368
+ # An approver grants their own approval
369
+ issue.approve!(user: alice)
370
+
371
+ # An approver revokes their own approval; support may revoke on another's behalf
372
+ issue.revoke_approval!(user: alice)
373
+ issue.revoke_approval!(user: support_user, target_user_id: alice.id)
374
+
375
+ # Support removes approvers
376
+ issue.remove_approvers!(user_ids: [alice.id], user: current_user)
377
+ ```
378
+
379
+ ### Reading
380
+
381
+ ```ruby
382
+ issue.approvers # Array<PMS::Approval> — all records (pending + approved)
383
+ issue.pending_approvals # subset still pending
384
+ issue.approvals_required? # true iff approvers.present?
385
+ issue.fully_approved? # true iff approvals_required? && pending_approvals.empty?
386
+ ```
387
+
388
+ ### Pipeline gating
389
+
390
+ `PMS::PendingApprovalsError` is raised when any pending approval exists on the linked issue. Gated transitions:
391
+
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 = PMS::TestHelpers.build_issue
410
+ PMS::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
419
+ `issue_reminder_due.plan_my_stuff` events on waiting issues whose next milestone has passed; issues that exceed the
420
+ inactivity ceiling are auto-closed.
421
+
422
+ ### Waiting on user
423
+
424
+ A support user enters an issue into "waiting on user" state by either:
425
+
426
+ 1. Posting a comment with `waiting_on_reply: true`:
427
+
428
+ ```ruby
429
+ PMS::Comment.create!(
430
+ issue: issue,
431
+ body: "Can you confirm the date range?",
432
+ user: current_user,
433
+ waiting_on_reply: true,
434
+ )
435
+ ```
436
+
437
+ 2. Clicking the **Mark waiting** button on the mounted issue show page.
438
+
439
+ Both paths add the `waiting-on-user` label, set `issue.metadata.waiting_on_user_at = now`, and schedule the first reminder.
440
+
441
+ When an end-user posts a comment through the gem, the label is removed and the waiting state clears. If the issue had
442
+ been auto-closed for inactivity, the reply also reopens it and fires `issue_reopened_by_reply.plan_my_stuff`.
443
+
444
+ ### Waiting on approval
445
+
446
+ 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.
447
+
448
+ ### Reminder schedule
449
+
450
+ Reminders fire at specific day counts since waiting started:
451
+
452
+ ```ruby
453
+ config.reminder_days = [1, 3, 7, 10, 14, 18] # default
454
+ ```
455
+
456
+ Override per-issue:
457
+
458
+ ```ruby
459
+ issue.metadata.reminder_days = [2, 5, 9]
460
+ issue.save!
461
+ ```
462
+
463
+ Each reminder emits `issue_reminder_due.plan_my_stuff` with payload:
464
+
465
+ | Field | Type | Notes |
466
+ |---|---|---|
467
+ | `issue` | `PMS::Issue` | the waiting issue |
468
+ | `waiting_kind` | `:user` or `:approval` | which clock drove the reminder |
469
+ | `days_waiting` | Integer | days since the waiting clock started |
470
+ | `reminder_day` | Integer | the matched entry in `reminder_days` |
471
+ | `last_activity_at` | Time | last comment or `issue.updated_at` (informational) |
472
+ | `pending_approvers` | `Array<User>` | resolved via `UserResolver`; only for `:approval` |
473
+
474
+ Subscribe in your app to deliver (email, Slack, etc.):
475
+
476
+ ```ruby
477
+ ActiveSupport::Notifications.subscribe('issue_reminder_due.plan_my_stuff') do |event|
478
+ issue = event.payload[:issue]
479
+ case event.payload[:waiting_kind]
480
+ when :user then NotifyUserMailer.reminder(issue).deliver_later
481
+ when :approval then NotifyApproversMailer.reminder(issue, event.payload[:pending_approvers]).deliver_later
482
+ end
483
+ end
213
484
  ```
214
485
 
486
+ ### Inactivity auto-close
487
+
488
+ When an issue's `days_waiting` reaches `config.inactivity_close_days` (default 30), the sweep:
489
+
490
+ - Closes the issue
491
+ - Sets `issue.metadata.closed_by_inactivity = true`
492
+ - Adds the `user-inactive` label (configurable via `config.user_inactive_label`)
493
+ - Emits `issue_closed_inactive.plan_my_stuff` (with `reason: :inactivity`); the regular `issue_closed` event is
494
+ suppressed so subscribers listening to "close" don't double-fire for automated closes
495
+
496
+ An end-user reply on a `closed_by_inactivity` issue auto-reopens it, strips the `user-inactive` label, and fires
497
+ `issue_reopened_by_reply.plan_my_stuff`.
498
+
499
+ ### Scheduling the sweep
500
+
501
+ The gem ships `PMS::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:
502
+
503
+ ```bash
504
+ # Enqueue one job per configured repo:
505
+ rake plan_my_stuff:reminders:sweep
506
+
507
+ # Or target a single repo:
508
+ rake plan_my_stuff:reminders:sweep REPO=element
509
+ ```
510
+
511
+ You can also schedule from Ruby if you prefer:
512
+
513
+ ```ruby
514
+ PMS::RemindersSweepJob.requeue(:your_repo_key) # schedules for next_run
515
+ ```
516
+
517
+ `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.
518
+
519
+ Override the cadence by subclassing:
520
+
521
+ ```ruby
522
+ class MyRemindersJob < PMS::RemindersSweepJob
523
+ def self.next_run
524
+ 4.hours.from_now.utc # run every 4 hours
525
+ end
526
+ end
527
+ ```
528
+
529
+ ### Disabling
530
+
531
+ ```ruby
532
+ config.reminders_enabled = false
533
+ ```
534
+
535
+ The sweep becomes a no-op but still self-requeues so re-enabling doesn't require manual intervention.
536
+
537
+ ## Auto-archiving
538
+
539
+ Closed PMS issues that have aged past `config.archive_closed_after_days` (default 90) are archived by the daily sweep. Archive means:
540
+
541
+ 1. Tag the issue with `config.archived_label` (default `archived`).
542
+ 2. Lock the conversation on GitHub (no new comments).
543
+ 3. Remove the issue from every Projects V2 board it sits on.
544
+ 4. Stamp `metadata.archived_at`.
545
+ 5. Emit `issue_archived.plan_my_stuff` with `reason: :aged_closed`.
546
+
547
+ The sweep runs inside `PMS::RemindersSweepJob` alongside the follow-up reminders sweep — same cadence, same rake task.
548
+
549
+ ### Exclusions
550
+
551
+ - **Non-PMS issues** (no `pms-metadata` marker) are skipped — the gem only archives what it created.
552
+ - **Inactivity-closed issues** (`metadata.closed_by_inactivity == true`) are skipped — `T-051` already handled the close.
553
+ - **Already-archived issues** (`metadata.archived_at` set or archived label present) are skipped.
554
+
555
+ ### Manual archive
556
+
557
+ ```ruby
558
+ issue = PMS::Issue.find(123)
559
+ issue.archive!
560
+ ```
561
+
562
+ No-op if the issue is open, already archived, or already carries the archived label.
563
+
564
+ ### Config
565
+
566
+ ```ruby
567
+ config.archiving_enabled = true
568
+ config.archive_closed_after_days = 90
569
+ config.archived_label = 'archived'
570
+ ```
571
+
572
+ 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.
573
+
574
+ ### Subscribing
575
+
576
+ ```ruby
577
+ ActiveSupport::Notifications.subscribe('issue_archived.plan_my_stuff') do |event|
578
+ issue = event.payload[:issue]
579
+ ArchiveMailer.notify(issue).deliver_later
580
+ end
581
+ ```
582
+
583
+ ### Disabling
584
+
585
+ ```ruby
586
+ config.archiving_enabled = false
587
+ ```
588
+
589
+ Reminders keep running; only the archive pass is suppressed.
590
+
591
+ ## Caching
592
+
593
+ 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").
594
+
595
+ | Cached | Method |
596
+ |---|---|
597
+ | `Issue.find`, `Issue.list` | ETag |
598
+ | `Comment.find`, `Comment.list` | ETag |
599
+ | `Project.find`, `ProjectItem` reads | not cached (GraphQL, no HTTP ETag — deferred) |
600
+
601
+ 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.
602
+
603
+ ### Configuration
604
+
605
+ ```ruby
606
+ config.cache_enabled = true # default; set false to bypass entirely
607
+ config.cache_version = 'v2' # opaque string baked into every cache key;
608
+ # bump to orphan all cached entries after deploy
609
+ ```
610
+
611
+ Cache keys include both the gem's `CACHE_VERSION` ("v1") and `config.cache_version`, so gem upgrades and app-side invalidations are independent.
612
+
215
613
  ## Metadata
216
614
 
217
- All state lives on GitHub. Issue and comment metadata is stored as a hidden HTML comment on the first line of the body:
615
+ All state lives on GitHub. Each domain class carries a typed metadata object embedded in the corresponding GitHub
616
+ body (issue/comment body, project readme) as a collapsible `<details>` block containing a JSON code fence, which
617
+ GitHub renders in-page:
618
+
619
+ ````markdown
620
+ <details><summary>pms-metadata</summary>
621
+
622
+ ```json
623
+ {"schema_version":1,"gem_version":"0.0.0","app_name":"MyApp", ...}
624
+ ```
625
+
626
+ </details>
627
+ ````
628
+
629
+ `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.
630
+
631
+ ### Metadata classes
632
+
633
+ | Class | Stored on | Notable fields |
634
+ |---|---|---|
635
+ | `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` |
636
+ | `CommentMetadata` | Comment body | `issue_body` (marks the issue's body-comment), `custom_fields` |
637
+ | `ProjectMetadata` | Project readme | `kind: "project"` |
638
+ | `TestingProjectMetadata` | Testing project readme | `kind: "testing"`, `subject_urls`, `due_date`, `deadline_miss_reason` |
639
+ | `ProjectItemMetadata` | — | minimal; reserved for future fields |
640
+
641
+ All metadata classes share `schema_version`, `gem_version`, `app_name`, `created_by`, `visibility`, and `custom_fields` via `BaseMetadata`.
642
+
643
+ ### Custom fields
644
+
645
+ `custom_fields` is the escape hatch for app-specific data. Declare a schema in the initializer:
646
+
647
+ ```ruby
648
+ PlanMyStuff.configure do |config|
649
+ # Shared across everything that has custom_fields
650
+ config.custom_fields = {
651
+ ticket_type: { type: :string, required: true },
652
+ }
653
+
654
+ # Resource-specific (merged on top of shared)
655
+ config.issue_custom_fields = { notification_recipients: { type: :array } }
656
+ config.comment_custom_fields = { internal_note: { type: :boolean } }
657
+ config.project_custom_fields = { team: { type: :string } }
658
+ config.testing_custom_fields = { test_plan_url: { type: :string } }
659
+ end
660
+ ```
218
661
 
219
- ```html
220
- <!-- pms-metadata:{"gem_version":"0.0.0","app_name":"MyApp","created_at":"2026-03-24T10:00:00Z",...} -->
662
+ Access by name or hash:
663
+
664
+ ```ruby
665
+ issue.metadata.custom_fields.ticket_type # method-style
666
+ issue.metadata.custom_fields[:ticket_type] = 'bug'
667
+ issue.save! # validates; raises ActiveModel::ValidationError
221
668
  ```
222
669
 
223
- This is invisible when rendered on GitHub and parsed automatically by the gem into typed objects (`PMS::IssueMetadata`, `PMS::CommentMetadata`).
670
+ ## Release pipeline
671
+
672
+ `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.
673
+
674
+ ### Statuses
675
+
676
+ `PMS::Pipeline::Status::ALL` (in order): `Started` -> `In Review` -> `Ready for Release` -> `Release in Progress` -> `Completed`. (Testing runs orthogonally as a separate single-select field flipped by `request_testing!`, not as a Status value.)
677
+
678
+ Display names can be overridden in the consuming app via `config.pipeline_statuses`; the constants above remain the internal identifiers.
679
+
680
+ ### Transitions
681
+
682
+ ```ruby
683
+ # Add an issue to the pipeline board
684
+ item = PMS::ProjectItem.create!(issue, project_number: PMS::Pipeline.resolve_pipeline_project_number!)
685
+
686
+ # Forward transitions
687
+ PMS::Pipeline.take!(item) # Started
688
+ PMS::Pipeline.mark_in_review!(item) # In Review
689
+ PMS::Pipeline.request_testing!(item) # flips Testing single-select to active
690
+ PMS::Pipeline.mark_ready_for_release!(item) # Ready for Release
691
+
692
+ # Deployment-driven transitions
693
+ PMS::Pipeline.start_deployment!(commit_sha: 'abc123…') # every Ready item whose issue is in the commit -> Release in Progress
694
+ PMS::Pipeline.complete_deployment!(item, deployment_id: 42) # Completed (when issue.metadata.auto_complete)
695
+
696
+ # Remove from pipeline (deletes the project item)
697
+ PMS::Pipeline.remove!(item)
698
+ ```
699
+
700
+ Forward transitions call `Pipeline.guard_approvals!(issue)`, which raises `PMS::PendingApprovalsError` if the linked issue has un-approved required approvers. Batch/automated transitions (`start_deployment!`, `complete_deployment!`, `remove!`) skip the guard on purpose.
701
+
702
+ ### Webhook-driven automation
703
+
704
+ The engine mounts two webhook endpoints when the pipeline is enabled:
705
+
706
+ - `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!`).
707
+ - `POST /webhooks/aws` — takes CodeDeploy/SNS lifecycle events. On a successful deployment, the gem flips matching items to `Completed`.
708
+
709
+ On a successful AWS deployment the gem completes each matching item individually (one
710
+ `pipeline_completed.plan_my_stuff` event apiece), then fires a single
711
+ `pipeline_deployment_completed.plan_my_stuff` batch event carrying every item that actually transitioned
712
+ (payload: `:project_items`, `:issue_numbers`, `:commit_sha`). It is skipped when nothing transitioned, so subscribers
713
+ (release-notes mailer, Slack summary, changelog generator) get one clean "deployment finished" signal
714
+ without debouncing the per-item events.
715
+
716
+ See `designs/release_cycle/plan.md` for the full design, including the `projects_v2_item` path that fires
717
+ `Pipeline.take!` when a human drags an item to Started on github.com.
718
+
719
+ ### "Take" button
720
+
721
+ 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
722
 
225
723
  ## Verify setup
226
724
 
@@ -239,7 +737,7 @@ The gem provides test helpers for consuming apps:
239
737
  require 'plan_my_stuff/test_helpers'
240
738
 
241
739
  RSpec.configure do |config|
242
- config.include PlanMyStuff::TestHelpers
740
+ config.include PMS::TestHelpers
243
741
  end
244
742
  ```
245
743
 
@@ -259,26 +757,52 @@ expect_pms_item_moved(status: 'In Review')
259
757
 
260
758
  ## Engine routes
261
759
 
262
- The engine provides these routes (all under the configured mount point):
760
+ 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.
761
+
762
+ ### Issues
263
763
 
264
764
  | Route | Action |
265
765
  |---|---|
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 |
766
+ | `GET /issues`, `GET /issues/new`, `POST /issues` | index, new, create |
767
+ | `GET /issues/:id`, `GET /issues/:id/edit`, `PATCH /issues/:id` | show, edit, update |
768
+ | `POST /issues/:issue_id/closure` / `DELETE …/closure` | close / reopen |
769
+ | `POST /issues/:issue_id/waiting` / `DELETE …/waiting` | mark / clear waiting-on-user |
770
+ | `POST /issues/:issue_id/viewers` / `DELETE …/viewers/:id` | add / remove viewer |
771
+ | `POST /issues/:issue_id/take` (pipeline) | Take button -> `Pipeline.take!` |
772
+ | `POST /issues/:issue_id/comments`, `GET …/comments/:id/edit`, `PATCH …/comments/:id` | create / edit / update comment |
773
+ | `POST /issues/:issue_id/labels` / `DELETE …/labels/:id` | add / remove label |
774
+ | `POST /issues/:issue_id/links` / `DELETE …/links/:id` | link / unlink related issue |
775
+ | `POST /issues/:issue_id/approvals`, `PATCH …/approvals/:id`, `DELETE …/approvals/:id` | request / grant or revoke / remove approver |
776
+
777
+ ### Projects
778
+
779
+ | Route | Action |
780
+ |---|---|
781
+ | `GET /projects`, `GET /projects/new`, `POST /projects` | index, new, create |
782
+ | `GET /projects/:id`, `GET /projects/:id/edit`, `PATCH /projects/:id` | show, edit, update |
783
+ | `POST /projects/:project_id/items` | add item |
784
+ | `PATCH /projects/:project_id/items/:item_id/status` | move item to a status column |
785
+ | `PATCH /projects/:project_id/items/:item_id/assignment` / `DELETE …/assignment` | assign / unassign |
786
+
787
+ ### Testing projects
788
+
789
+ | Route | Action |
790
+ |---|---|
791
+ | `GET /testing_projects/new`, `POST /testing_projects` | new, create |
792
+ | `GET /testing_projects/:id`, `GET …/:id/edit`, `PATCH …/:id` | show, edit, update |
793
+ | `GET /testing_projects/:testing_project_id/items/new`, `POST …/items` | new, create item |
794
+ | `PATCH /testing_projects/:testing_project_id/items/:item_id/status` | move item |
795
+ | `GET …/items/:item_id/result/new`, `POST …/items/:item_id/result` | record pass/fail result |
796
+
797
+ ### Webhooks (pipeline only)
798
+
799
+ | Route | Action |
800
+ |---|---|
801
+ | `POST /webhooks/github` | GitHub PR / projects_v2_item events |
802
+ | `POST /webhooks/aws` | AWS CodeDeploy / SNS lifecycle events |
803
+
804
+ ## Controller overrides
805
+
806
+ See [CONFIGURATION.md](CONFIGURATION.md#controller-overrides) for the full walkthrough including subclassing,
807
+ per-route registration, and the per-action block hook.
284
808