plan_my_stuff 0.6.0 → 0.8.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/README.md +100 -103
  4. data/app/controllers/plan_my_stuff/application_controller.rb +22 -3
  5. data/app/controllers/plan_my_stuff/comments_controller.rb +14 -16
  6. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +23 -13
  7. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +7 -5
  8. data/app/controllers/plan_my_stuff/issues/links_controller.rb +14 -18
  9. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +99 -28
  10. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +13 -5
  11. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +7 -5
  12. data/app/controllers/plan_my_stuff/issues_controller.rb +24 -28
  13. data/app/controllers/plan_my_stuff/labels_controller.rb +21 -5
  14. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +13 -6
  15. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +5 -4
  16. data/app/controllers/plan_my_stuff/project_items_controller.rb +30 -5
  17. data/app/controllers/plan_my_stuff/projects_controller.rb +16 -16
  18. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +21 -11
  19. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +9 -4
  20. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +30 -14
  21. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +50 -17
  22. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +32 -49
  23. data/app/jobs/plan_my_stuff/application_job.rb +2 -3
  24. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +15 -22
  25. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  26. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +1 -0
  27. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  28. data/app/views/plan_my_stuff/issues/index.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +23 -2
  31. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +1 -0
  32. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -1
  33. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +50 -7
  34. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -1
  35. data/app/views/plan_my_stuff/issues/show.html.erb +5 -2
  36. data/app/views/plan_my_stuff/partials/_flash.html.erb +4 -0
  37. data/app/views/plan_my_stuff/projects/edit.html.erb +1 -3
  38. data/app/views/plan_my_stuff/projects/index.html.erb +1 -1
  39. data/app/views/plan_my_stuff/projects/new.html.erb +1 -3
  40. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +1 -0
  41. data/app/views/plan_my_stuff/projects/show.html.erb +13 -3
  42. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +1 -3
  43. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +1 -3
  44. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +1 -3
  45. data/app/views/plan_my_stuff/testing_projects/new.html.erb +1 -3
  46. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +4 -3
  47. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +1 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +1 -0
  49. data/app/views/plan_my_stuff/testing_projects/show.html.erb +2 -2
  50. data/config/routes.rb +2 -2
  51. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +56 -3
  52. data/lib/plan_my_stuff/approval.rb +12 -4
  53. data/lib/plan_my_stuff/aws_sns_simulator.rb +12 -6
  54. data/lib/plan_my_stuff/base_metadata.rb +4 -15
  55. data/lib/plan_my_stuff/base_project.rb +68 -55
  56. data/lib/plan_my_stuff/base_project_item.rb +61 -57
  57. data/lib/plan_my_stuff/base_project_metadata.rb +1 -1
  58. data/lib/plan_my_stuff/client.rb +136 -48
  59. data/lib/plan_my_stuff/comment.rb +57 -57
  60. data/lib/plan_my_stuff/comment_metadata.rb +1 -1
  61. data/lib/plan_my_stuff/configuration.rb +95 -82
  62. data/lib/plan_my_stuff/errors.rb +10 -10
  63. data/lib/plan_my_stuff/graphql/queries.rb +1 -1
  64. data/lib/plan_my_stuff/issue.rb +501 -322
  65. data/lib/plan_my_stuff/issue_metadata.rb +10 -10
  66. data/lib/plan_my_stuff/label.rb +32 -16
  67. data/lib/plan_my_stuff/link.rb +15 -15
  68. data/lib/plan_my_stuff/markdown.rb +12 -6
  69. data/lib/plan_my_stuff/metadata_parser.rb +3 -1
  70. data/lib/plan_my_stuff/notifications.rb +1 -1
  71. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +2 -2
  72. data/lib/plan_my_stuff/pipeline/issue_linker.rb +1 -1
  73. data/lib/plan_my_stuff/pipeline.rb +61 -83
  74. data/lib/plan_my_stuff/project.rb +4 -4
  75. data/lib/plan_my_stuff/project_item_metadata.rb +1 -1
  76. data/lib/plan_my_stuff/project_metadata.rb +1 -1
  77. data/lib/plan_my_stuff/reminders/closer.rb +1 -1
  78. data/lib/plan_my_stuff/reminders/fire.rb +3 -3
  79. data/lib/plan_my_stuff/reminders/sweep.rb +4 -4
  80. data/lib/plan_my_stuff/repo.rb +12 -6
  81. data/lib/plan_my_stuff/test_helpers.rb +11 -11
  82. data/lib/plan_my_stuff/testing_project.rb +12 -11
  83. data/lib/plan_my_stuff/testing_project_item.rb +11 -9
  84. data/lib/plan_my_stuff/testing_project_metadata.rb +2 -2
  85. data/lib/plan_my_stuff/version.rb +1 -1
  86. data/lib/plan_my_stuff/webhook_replayer.rb +14 -2
  87. data/lib/plan_my_stuff.rb +26 -2
  88. data/lib/tasks/plan_my_stuff.rake +33 -20
  89. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69661ace546ff5f775e0e9fb5e4a153c3aca9e68da0b568e85238d8b66ef620a
4
- data.tar.gz: e0698c727fe6c3f1e8e135d0a2b1860731188dc01496065e3622a0a1d6ef0c45
3
+ metadata.gz: 6799008cb353423175bdd3132e0fa5f14981b3710378c3b0886a8e9a2394b610
4
+ data.tar.gz: 75b8336ec9aabd1d77a2642de77c9e46310dda3da49e6abd97b997927e906d05
5
5
  SHA512:
6
- metadata.gz: 876ac7c9c1baf371e09d81535324ae9a35bcd1c3a30509931395e274a229fcf25397ba1377450cb6a8ad6465f0474174ea664579b59f0d407c9b83ee08523647
7
- data.tar.gz: 89c4a1865f38c614c05d802c02d267b0ff2e83339da936403fac08f3d98476a6b87b722eecf422b5ee8f101f5db1ce75e8d3f836a3f54dd0ca86713df3146feb
6
+ metadata.gz: fdb5b8790905ff4f529496ad2a5c63b13a59a58ce524a8df79b20b0efc536854bc986f0e2fe7896b77bfe9b0a15598b6212a83991d46c9925e4dbf9eecab4871
7
+ data.tar.gz: b5c150efa84a7ed3143935216d0ac52ae9c8fa7d99cb45b6add6552c13f3b66fad2f604e1aa8871cb05fc69b3aa16e650bce14a60ad1490d0650af3a3e646d91
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Breaking
6
+
7
+ - Issue show "Take" button is now hidden when the issue already has assignees, and `Issues::TakesController#create` rejects the request with a flash error naming the existing assignee. Best-effort race guard for two users clicking Take simultaneously — without it, GitHub silently piles the second user on as a co-assignee.
8
+ - Guard lives in the controller, not `Pipeline.take!` itself, because webhook paths (`handle_issue_assigned`, `handle_projects_v2_item`, `handle_draft_opened`, `handle_converted_to_draft`) legitimately call `take!` on already-assigned issues
9
+ - `Issue#add_viewers` / `Issue#remove_viewers` renamed to `Issue#add_viewers!` / `Issue#remove_viewers!` for consistency with other mutating instance methods (`approve!`, `close!`, etc.)
10
+ - `Approval#status` extended to a 3-state model (`pending`, `approved`, `rejected`); `Approval` gains a `rejected_at` attribute serialized in `to_h` and the metadata blob. Existing approvals deserialize unchanged (`rejected_at` defaults to `nil`)
11
+ - `Issue#fully_approved?` now requires every approver to be `approved` — a single rejection blocks the gate until revoked. Previously equivalent to `pending_approvals.empty?`, which silently treated rejections as "done"
12
+ - `Issue#revoke_approval!` accepts either an `approved` or `rejected` source state (previously only `approved`); raises `ValidationError` when the target is still `pending`. Authorization error message updated to "another user's response"
13
+
14
+ ### Added
15
+
16
+ - `CONFIGURATION.md` documents every `PlanMyStuff::Configuration` option grouped by concern; `README.md` Configuration section trimmed to the two required options plus a link
17
+ - Install generator's initializer template now includes the four pipeline options previously missing from it: `pipeline_testing_field_name`, `pipeline_testing_values`, `pipeline_completion_purge_enabled`, `pipeline_completion_ttl_hours`
18
+ - Project show "Remove from project" button (and `DELETE /projects/:project_id/items/:id` route on `ProjectItemsController`) lets the user remove an item from a project board without bouncing out to GitHub. Calls `ProjectItem#destroy!` which fires `plan_my_stuff.project_item.removed`
19
+ - Issue show "Release" button (and `DELETE /issues/:issue_id/take` route on `Issues::TakesController`) lets a dev who took an issue undo their assignment from the same view, without bouncing out to GitHub or the project board. When the current user is the sole assignee the issue's GitHub assignees are cleared and the project item is removed from the pipeline (`project_item.destroy!`); when other assignees remain the current user is unassigned via `project_item.assign!(remaining)` and the item stays on the project
20
+ - `config.import_access_token` classic PAT (requires `repo` scope) used exclusively for the Issues Import API (`golden-comet-preview`); fine-grained tokens are not supported by that endpoint. Optional — defaults to `nil`
21
+ - `Issue.import!(payloads)` POSTs an `Array<Hash>` (one POST per payload) to GitHub's "Import Issues" preview endpoint and returns one status hash per input. Each payload must include `:repo` plus the GitHub-shaped `:issue` / `:comments` keys; payloads are passed through unchanged otherwise
22
+ - `Issue.check_import!(import_id, repo:)` polls a previously-submitted import for its status
23
+ - `Issue#created_at` / `Comment#created_at` attributes hydrated from GitHub's `created_at` field
24
+ - `Issue#issue_type` reader hydrated from GitHub's native `type.name`. `Issue.create!` and `Issue.update!` accept an `issue_type:` kwarg as a String (passed through), Symbol shortcut (`:bug`, `:feature`, `:it_issue`, `:other`, `:performance`, `:question`, `:task`), or `nil`. On `update!`, omitting the kwarg leaves the type untouched; passing `nil` clears it
25
+ - `config.issue_types` Hash{String => String} maps canonical type names to org-specific display names so consuming apps can rename without touching call sites (e.g. `{ 'Feature' => 'Enhancement' }`); missing keys pass through unchanged
26
+ - `config.controller_rescue` Proc invoked from every user-facing controller `rescue` block (after the gem logs the error and stack trace) so consuming apps can forward swallowed errors to their monitoring service
27
+ - `Issue#reject!(user:)` symmetric with `approve!`; accepts either `pending` or `approved` source state and raises `ValidationError` when already rejected. Fires `plan_my_stuff.issue.approval_rejected` and, when the flip drops the issue out of `fully_approved?`, `approvals_invalidated(trigger: :rejected)`
28
+ - `Issue#approve!` now accepts either `pending` or `rejected` source state; raises `ValidationError` only on `approved -> approved`. Clears `rejected_at` on the rejected -> approved transition
29
+ - `Issue#rejected_approvals` reader returns the subset of approvers with `status == 'rejected'`
30
+ - New events: `plan_my_stuff.issue.approval_rejected` and `plan_my_stuff.issue.rejection_revoked` (the latter fires when revoking a rejection back to pending)
31
+ - Approvals partial renders a `Reject` button alongside `Approve` for pending approvers, a `rejected at <ts>` row, a parenthetical rejected count in the header, and a `Revoke` button for either non-pending state
32
+
33
+ ## 0.7.0
34
+
35
+ ### Added
36
+
37
+ - `Issue#user_link` returns the per-issue URL in the consuming app, computed from `config.issues_url_prefix` + issue number
38
+
39
+ ### Changed
40
+
41
+ - GitHub issues created/updated by PMS now render a markdown link `[Org/Repo#number](user_link)` as the visible body (previously empty); skipped when `issues_url_prefix` is unset
42
+
3
43
  ## 0.6.0
4
44
 
5
45
  ### Breaking
@@ -7,7 +47,7 @@
7
47
  - `Pipeline.submit!` removed; consuming apps that called it directly should switch to `ProjectItem.create!` + `Pipeline.take!`
8
48
  - `Pipeline::Status::SUBMITTED` and `Pipeline::Status::TESTING` constants removed; `Status::ALL` no longer includes them
9
49
  - `Pipeline.request_testing!` no longer moves the `Status` field — it now writes to a separate `Testing` single-select custom field on the pipeline project. The pipeline project must have a `Testing` field with `Testing` / `Not testing` options
10
- - Active pipeline status set is now `Started In Review Ready for Release Release in Progress Completed`
50
+ - Active pipeline status set is now `Started -> In Review -> Ready for Release -> Release in Progress -> Completed`
11
51
 
12
52
  ### Added
13
53
 
data/README.md CHANGED
@@ -43,7 +43,7 @@ The gem supports three markdown rendering options. The chosen gem must be in you
43
43
  | None (raw) | Nothing | `config.markdown_renderer = nil` |
44
44
 
45
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`.
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
47
 
48
48
  ### Overriding views
49
49
 
@@ -55,58 +55,21 @@ rails generate plan_my_stuff:views
55
55
 
56
56
  ## Configuration
57
57
 
58
+ The install generator drops a fully-commented initializer at
59
+ `config/initializers/plan_my_stuff.rb`. Two options are required:
60
+
58
61
  ```ruby
59
- # config/initializers/plan_my_stuff.rb
60
- PlanMyStuff.configure do |config|
61
- # Auth (PAT from a bot account with repo + project scopes)
62
+ PMS.configure do |config|
62
63
  config.access_token = Rails.application.credentials.dig(:plan_my_stuff, :github_token)
63
-
64
- # Organization
65
64
  config.organization = 'YourOrganization'
66
-
67
- # Named repo configs
68
- config.repos[:marketing_website] = 'YourOrganization/MarketingWebsite'
69
- config.repos[:cms_website] = 'YourOrganization/CMSWebsite'
70
- config.default_repo = :cms_website
71
-
72
- # Default project board
73
- config.default_project_number = 123
74
-
75
- # User class (your app's model)
76
- config.user_class = 'User'
77
- config.display_name_method = :full_name
78
- config.user_id_method = :id
79
-
80
- # Support role check (symbol or proc)
81
- config.support_method = :support?
82
-
83
- # Markdown rendering (:commonmarker, :redcarpet, or nil)
84
- config.markdown_renderer = :commonmarker
85
-
86
- # Request gateway (proc or nil; nil = always send)
87
- config.should_send_request = nil
88
-
89
- # Background jobs (when gateway defers a request)
90
- config.job_classes = {
91
- create_ticket: 'PmsCreateTicketJob',
92
- post_comment: 'PmsPostCommentJob',
93
- update_status: 'PmsUpdateStatusJob'
94
- }
95
-
96
- # Fallback actor for notification events when a caller does not pass user:
97
- config.current_user = -> { Current.user }
98
-
99
- # Custom fields (stored in issue/comment metadata)
100
- config.custom_fields = {
101
- ticket_type: { type: :string },
102
- notification_recipients: { type: :array }
103
- }
104
-
105
- # App name (appears in metadata)
106
- config.app_name = 'MyApp'
107
65
  end
108
66
  ```
109
67
 
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).
72
+
110
73
  The `PMS` alias is available for brevity: `PMS.configure`, `PMS::Issue.find`, etc.
111
74
 
112
75
  ## Architecture
@@ -115,7 +78,7 @@ All state lives on GitHub. The gem exposes it through ActiveRecord-style domain
115
78
 
116
79
  ### Domain class hierarchy
117
80
 
118
- ```
81
+ ```text
119
82
  ApplicationRecord # includes ActiveModel::Model, Attributes, Serializers::JSON
120
83
  ├── Issue # GitHub Issue
121
84
  ├── Comment # GitHub Issue comment
@@ -160,10 +123,10 @@ issue = PMS::Issue.create!(
160
123
  )
161
124
 
162
125
  # Find
163
- issue = PMS::Issue.find(repo: :cms_website, number: 123)
126
+ issue = PMS::Issue.find(123, repo: :cms_website)
164
127
  issue.title
165
128
  issue.body # body without metadata
166
- issue.metadata # PlanMyStuff::IssueMetadata
129
+ issue.metadata # PMS::IssueMetadata
167
130
  issue.visible_to?(user) # visibility check
168
131
  issue.comments # all comments
169
132
  issue.pms_comments # only PMS-created comments
@@ -179,28 +142,63 @@ issue.update!(state: :closed)
179
142
  issue.update!(state: :open)
180
143
 
181
144
  # Viewer management (visibility allowlist)
182
- issue.add_viewers(user_ids: [5, 12], user: current_user)
183
- issue.remove_viewers(user_ids: [5], user: current_user)
145
+ issue.add_viewers!(user_ids: [5, 12], user: current_user)
146
+ issue.remove_viewers!(user_ids: [5], user: current_user)
184
147
  ```
185
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' }
176
+ ```
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
+
186
185
  ### Comments
187
186
 
188
187
  ```ruby
189
188
  # Create
190
189
  comment = PMS::Comment.create!(
191
- repo: :cms_website,
192
- issue_number: 123,
190
+ issue: issue,
193
191
  body: 'Deployed fix to staging, please retest.',
194
192
  user: current_user,
195
193
  visibility: :public # or :internal (support-only)
196
194
  )
197
195
 
198
196
  # List
199
- comments = PMS::Comment.list(repo: :cms_website, issue_number: 123)
200
- 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)
201
199
 
202
200
  comment.body # visible text
203
- comment.metadata # PlanMyStuff::CommentMetadata (nil for non-PMS comments)
201
+ comment.metadata # PMS::CommentMetadata (nil for non-PMS comments)
204
202
  comment.visibility # :public, :internal, or nil
205
203
  comment.pms_comment? # true/false
206
204
  comment.visible_to?(user)
@@ -209,8 +207,8 @@ comment.visible_to?(user)
209
207
  ### Labels
210
208
 
211
209
  ```ruby
212
- PMS::Label.add(repo: :cms_website, issue_number: 123, labels: ['in-progress'])
213
- 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'])
214
212
  ```
215
213
 
216
214
  ### Projects
@@ -233,10 +231,10 @@ item.move_to!('In Review')
233
231
  item.assign!('octocat')
234
232
 
235
233
  # Add existing issue to project
236
- PMS::Project.add_item(project_number: 14, issue_repo: :cms_website, issue_number: 123)
234
+ PMS::ProjectItem.create!(issue, project_number: 14)
237
235
 
238
236
  # Add draft item
239
- 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)
240
238
  ```
241
239
 
242
240
  ### Testing tracking
@@ -258,11 +256,11 @@ Per-item sign-off:
258
256
 
259
257
  ```ruby
260
258
  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')
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')
266
264
  ```
267
265
 
268
266
  A board and its items are editable through the mounted UI at `/testing_projects`.
@@ -279,8 +277,8 @@ Every PMS lifecycle write fires an `ActiveSupport::Notifications` event under th
279
277
  | `plan_my_stuff.issue.updated` | `issue.save!` / `issue.update!` (any non-state change) |
280
278
  | `plan_my_stuff.issue.closed` | `issue.update!(state: :closed)` |
281
279
  | `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` |
280
+ | `plan_my_stuff.issue.viewers_added` | `issue.add_viewers!` |
281
+ | `plan_my_stuff.issue.viewers_removed` | `issue.remove_viewers!` |
284
282
  | `plan_my_stuff.issue.approval_requested` | `issue.request_approvals!` |
285
283
  | `plan_my_stuff.issue.approval_granted` | `issue.approve!` |
286
284
  | `plan_my_stuff.issue.approval_revoked` | `issue.revoke_approval!` |
@@ -288,9 +286,9 @@ Every PMS lifecycle write fires an `ActiveSupport::Notifications` event under th
288
286
  | `plan_my_stuff.issue.approvals_invalidated` | aggregate — fires when a revoke (or new approver) drops the set out of fully-approved |
289
287
  | `plan_my_stuff.comment.created` | `Comment.create!` |
290
288
  | `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` |
289
+ | `plan_my_stuff.label.added` | `Label.add!` |
290
+ | `plan_my_stuff.label.removed` | `Label.remove!` |
291
+ | `plan_my_stuff.project_item.added` | `ProjectItem.create!` (issue or `draft: true`) |
294
292
  | `plan_my_stuff.project_item.removed` | `project_item.destroy!` |
295
293
  | `plan_my_stuff.project_item.assigned` | `project_item.assign!` |
296
294
  | `plan_my_stuff.project_item.status_changed` | `project_item.move_to!` |
@@ -306,14 +304,14 @@ All events include:
306
304
 
307
305
  Additional keys by event:
308
306
 
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`
307
+ - `issue.updated` / `comment.updated` -> `:changes` — hash of `{ attr => [old, new] }`
308
+ - `issue.viewers_added` / `viewers_removed` -> `:user_ids`
309
+ - `issue.approval_requested` -> `:approvals` (array of newly-added `PMS::Approval`)
310
+ - `issue.approval_granted` / `approval_revoked` -> `:approval` (the flipped `PMS::Approval`)
311
+ - `issue.approvals_invalidated` -> `:trigger` — `:revoked` or `:approver_added`
312
+ - `label.added` / `label.removed` -> `:labels`
313
+ - `project_item.assigned` -> `:assignees`
314
+ - `project_item.status_changed` -> `:status`, `:previous_status`
317
315
 
318
316
  ### Actor resolution
319
317
 
@@ -345,7 +343,7 @@ expect {
345
343
  }.to(have_fired_event('plan_my_stuff.issue.created').with(user: alice))
346
344
 
347
345
  # Raw capture
348
- events = PlanMyStuff::TestHelpers::Notifications.capture do
346
+ events = PMS::TestHelpers::Notifications.capture do
349
347
  PMS::Issue.create!(...)
350
348
  end
351
349
  events.first[:name] # => "plan_my_stuff.issue.created"
@@ -380,15 +378,14 @@ issue.remove_approvers!(user_ids: [alice.id], user: current_user)
380
378
  ```ruby
381
379
  issue.approvers # Array<PMS::Approval> — all records (pending + approved)
382
380
  issue.pending_approvals # subset still pending
383
- issue.approvals_required? # true iff approvers.any?
381
+ issue.approvals_required? # true iff approvers.present?
384
382
  issue.fully_approved? # true iff approvals_required? && pending_approvals.empty?
385
383
  ```
386
384
 
387
385
  ### Pipeline gating
388
386
 
389
- `PMS::Pipeline::PendingApprovalsError` is raised when any pending approval exists on the linked issue. Gated transitions:
387
+ `PMS::PendingApprovalsError` is raised when any pending approval exists on the linked issue. Gated transitions:
390
388
 
391
- - `Pipeline.submit!`
392
389
  - `Pipeline.take!`
393
390
  - `Pipeline.mark_in_review!`
394
391
  - `Pipeline.request_testing!`
@@ -406,8 +403,8 @@ See the notifications catalog above — `approval_requested`, `approval_granted`
406
403
  require 'plan_my_stuff/test_helpers'
407
404
 
408
405
  # 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])
406
+ issue = PMS::TestHelpers.build_issue
407
+ PMS::TestHelpers.stub_approvals(issue, approved: [alice], pending: [bob])
411
408
 
412
409
  issue.fully_approved? # false
413
410
  issue.pending_approvals # [PMS::Approval(user_id: bob.id, status: 'pending')]
@@ -493,7 +490,7 @@ An end-user reply on a `closed_by_inactivity` issue auto-reopens it, strips the
493
490
 
494
491
  ### Scheduling the sweep
495
492
 
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:
493
+ 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:
497
494
 
498
495
  ```bash
499
496
  # Enqueue one job per configured repo:
@@ -506,7 +503,7 @@ rake plan_my_stuff:reminders:sweep REPO=element
506
503
  You can also schedule from Ruby if you prefer:
507
504
 
508
505
  ```ruby
509
- PlanMyStuff::RemindersSweepJob.requeue(:your_repo_key) # schedules for next_run
506
+ PMS::RemindersSweepJob.requeue(:your_repo_key) # schedules for next_run
510
507
  ```
511
508
 
512
509
  `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.
@@ -514,7 +511,7 @@ PlanMyStuff::RemindersSweepJob.requeue(:your_repo_key) # schedules for next_run
514
511
  Override the cadence by subclassing:
515
512
 
516
513
  ```ruby
517
- class MyRemindersJob < PlanMyStuff::RemindersSweepJob
514
+ class MyRemindersJob < PMS::RemindersSweepJob
518
515
  def self.next_run
519
516
  4.hours.from_now.utc # run every 4 hours
520
517
  end
@@ -539,7 +536,7 @@ Closed PMS issues that have aged past `config.archive_closed_after_days` (defaul
539
536
  4. Stamp `metadata.archived_at`.
540
537
  5. Emit `plan_my_stuff.issue.archived` with `reason: :aged_closed`.
541
538
 
542
- The sweep runs inside `PlanMyStuff::RemindersSweepJob` alongside the follow-up reminders sweep — same cadence, same rake task.
539
+ The sweep runs inside `PMS::RemindersSweepJob` alongside the follow-up reminders sweep — same cadence, same rake task.
543
540
 
544
541
  ### Exclusions
545
542
 
@@ -660,7 +657,7 @@ issue.save! # validates; raises ActiveMode
660
657
 
661
658
  ### Statuses
662
659
 
663
- `PMS::Pipeline::Status::ALL` (in order): `Submitted` → `Started` `In Review` `Testing` → `Ready for Release` `Release in Progress` `Completed`.
660
+ `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.)
664
661
 
665
662
  Display names can be overridden in the consuming app via `config.pipeline_statuses`; the constants above remain the internal identifiers.
666
663
 
@@ -668,36 +665,36 @@ Display names can be overridden in the consuming app via `config.pipeline_status
668
665
 
669
666
  ```ruby
670
667
  # Add an issue to the pipeline board
671
- item = PMS::Pipeline.submit!(issue, assignee: current_user.github_login)
668
+ item = PMS::ProjectItem.create!(issue, project_number: PMS::Pipeline.resolve_pipeline_project_number!)
672
669
 
673
670
  # Forward transitions
674
- PMS::Pipeline.take!(item) # Started
675
- PMS::Pipeline.mark_in_review!(item) # In Review
676
- PMS::Pipeline.request_testing!(item) # Testing
671
+ PMS::Pipeline.take!(item) # Started
672
+ PMS::Pipeline.mark_in_review!(item) # In Review
673
+ PMS::Pipeline.request_testing!(item) # flips Testing single-select to active
677
674
  PMS::Pipeline.mark_ready_for_release!(item) # Ready for Release
678
675
 
679
676
  # Deployment-driven transitions
680
- PMS::Pipeline.start_deployment!(commit_sha: 'abc123…') # every Ready item whose issue is in the commit Release in Progress
677
+ PMS::Pipeline.start_deployment!(commit_sha: 'abc123…') # every Ready item whose issue is in the commit -> Release in Progress
681
678
  PMS::Pipeline.complete_deployment!(item, deployment_id: 42) # Completed (when issue.metadata.auto_complete)
682
679
 
683
680
  # Remove from pipeline (deletes the project item)
684
681
  PMS::Pipeline.remove!(item)
685
682
  ```
686
683
 
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.
684
+ 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.
688
685
 
689
686
  ### Webhook-driven automation
690
687
 
691
688
  The engine mounts two webhook endpoints when the pipeline is enabled:
692
689
 
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!`).
690
+ - `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
691
  - `POST /webhooks/aws` — takes CodeDeploy/SNS lifecycle events. On a successful deployment, the gem flips matching items to `Completed`.
695
692
 
696
693
  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
694
 
698
695
  ### "Take" button
699
696
 
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).
697
+ 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).
701
698
 
702
699
  ## Verify setup
703
700
 
@@ -716,7 +713,7 @@ The gem provides test helpers for consuming apps:
716
713
  require 'plan_my_stuff/test_helpers'
717
714
 
718
715
  RSpec.configure do |config|
719
- config.include PlanMyStuff::TestHelpers
716
+ config.include PMS::TestHelpers
720
717
  end
721
718
  ```
722
719
 
@@ -747,7 +744,7 @@ All routes are under the engine's mount point. Each group can be disabled via `c
747
744
  | `POST /issues/:issue_id/closure` / `DELETE …/closure` | close / reopen |
748
745
  | `POST /issues/:issue_id/waiting` / `DELETE …/waiting` | mark / clear waiting-on-user |
749
746
  | `POST /issues/:issue_id/viewers` / `DELETE …/viewers/:id` | add / remove viewer |
750
- | `POST /issues/:issue_id/take` (pipeline) | Take button `Pipeline.take!` |
747
+ | `POST /issues/:issue_id/take` (pipeline) | Take button -> `Pipeline.take!` |
751
748
  | `POST /issues/:issue_id/comments`, `GET …/comments/:id/edit`, `PATCH …/comments/:id` | create / edit / update comment |
752
749
  | `POST /issues/:issue_id/labels` / `DELETE …/labels/:id` | add / remove label |
753
750
  | `POST /issues/:issue_id/links` / `DELETE …/links/:id` | link / unlink related issue |
@@ -786,7 +783,7 @@ Every mounted route resolves its controller through `config.controller_for(key)`
786
783
 
787
784
  ```ruby
788
785
  # app/controllers/my_app/issues_controller.rb
789
- class MyApp::IssuesController < PlanMyStuff::IssuesController
786
+ class MyApp::IssuesController < PMS::IssuesController
790
787
  before_action :authenticate_user!
791
788
  before_action :authorize_ticket_access
792
789
  end
@@ -799,9 +796,9 @@ PlanMyStuff.configure do |config|
799
796
  end
800
797
  ```
801
798
 
802
- Overridable keys (see `PlanMyStuff::Configuration::DEFAULT_CONTROLLERS`):
799
+ Overridable keys (see `PMS::Configuration::DEFAULT_CONTROLLERS`):
803
800
 
804
- ```
801
+ ```text
805
802
  issues issues/closures
806
803
  comments issues/viewers
807
804
  labels issues/takes
@@ -6,6 +6,7 @@ module PlanMyStuff
6
6
  helper Rails.application.routes.url_helpers
7
7
 
8
8
  before_action :authenticate_pms_user!
9
+ before_action :set_support_user
9
10
 
10
11
  private
11
12
 
@@ -23,8 +24,12 @@ module PlanMyStuff
23
24
  instance_exec(&pms_auth) if pms_auth
24
25
  end
25
26
 
26
- # Returns the current user for PMS visibility checks.
27
- # Delegates to the consuming app's current_user method.
27
+ # @return [void]
28
+ def set_support_user
29
+ @support_user = support_user?
30
+ end
31
+
32
+ # Returns the current user for PMS visibility checks. Delegates to the consuming app's current_user method.
28
33
  #
29
34
  # @return [Object, nil]
30
35
  #
@@ -34,7 +39,7 @@ module PlanMyStuff
34
39
 
35
40
  # @return [Boolean]
36
41
  def support_user?
37
- pms_current_user.present? && PMS::UserResolver.support?(pms_current_user)
42
+ pms_current_user.present? && PlanMyStuff::UserResolver.support?(pms_current_user)
38
43
  end
39
44
 
40
45
  # Redirects non-support users back with an error.
@@ -49,6 +54,20 @@ module PlanMyStuff
49
54
  redirect_to(path)
50
55
  end
51
56
 
57
+ # Logs +error+ and invokes the consuming app's +PlanMyStuff.configuration.controller_rescue+ hook so monitoring
58
+ # can fire even when the rescue swallows the error and redirects to a flash. Wired into every user-facing
59
+ # controller +rescue+.
60
+ #
61
+ # @param error [StandardError]
62
+ #
63
+ # @return [void]
64
+ #
65
+ def pms_handle_rescue(error)
66
+ Rails.logger.error("[PlanMyStuff] #{error.class}: #{error.message}")
67
+ Rails.logger.error(error.backtrace.join("\n")) if error.backtrace
68
+ PlanMyStuff.configuration.controller_rescue&.call(error)
69
+ end
70
+
52
71
  # Splits a comma-separated labels string into an array.
53
72
  #
54
73
  # @param labels_string [String, nil]
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- class CommentsController < ApplicationController
4
+ class CommentsController < PlanMyStuff::ApplicationController
5
5
  # POST /issues/:issue_id/comments
6
6
  def create
7
- @issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
7
+ @issue = PlanMyStuff::Issue.find(params[:issue_id].to_i, repo: params[:repo])
8
8
 
9
- PMS::Comment.create!(
9
+ PlanMyStuff::Comment.create!(
10
10
  issue: @issue,
11
11
  body: comment_params[:body],
12
12
  user: pms_current_user,
@@ -16,7 +16,8 @@ module PlanMyStuff
16
16
 
17
17
  flash[:success] = 'Comment was successfully created.'
18
18
  redirect_to(plan_my_stuff.issue_path(@issue.number, repo: @issue.repo.full_name))
19
- rescue PMS::LockedIssueError
19
+ rescue PlanMyStuff::LockedIssueError => e
20
+ pms_handle_rescue(e)
20
21
  flash[:error] = 'This issue is locked; no new comments can be posted.'
21
22
  redirect_to(plan_my_stuff.issue_path(@issue.number, repo: @issue.repo.full_name))
22
23
  end
@@ -26,8 +27,6 @@ module PlanMyStuff
26
27
  load_comment
27
28
  return unless @comment
28
29
  return redirect_to_issue if issue_body_comment?
29
-
30
- @support_user = support_user?
31
30
  return if can_edit?(@comment)
32
31
 
33
32
  redirect_to_unauthorized(plan_my_stuff.issue_path(@issue.number, repo: @issue.repo.full_name))
@@ -39,7 +38,6 @@ module PlanMyStuff
39
38
  return unless @comment
40
39
  return redirect_to_issue if issue_body_comment?
41
40
 
42
- @support_user = support_user?
43
41
  unless can_edit?(@comment)
44
42
  redirect_to_unauthorized(plan_my_stuff.issue_path(@issue.number, repo: @issue.repo.full_name))
45
43
 
@@ -49,13 +47,14 @@ module PlanMyStuff
49
47
  update_attrs = { body: comment_params[:body] }
50
48
  update_attrs[:visibility] = comment_params[:visibility].to_sym if @support_user && comment_params[:visibility]
51
49
 
52
- @comment.update!(**update_attrs)
50
+ @comment.update!(**update_attrs, user: pms_current_user)
53
51
 
54
52
  flash[:success] = 'Comment was successfully updated.'
55
53
  redirect_to(plan_my_stuff.issue_path(@issue.number, repo: @issue.repo.full_name))
56
- rescue PMS::StaleObjectError
54
+ rescue PlanMyStuff::StaleObjectError => e
55
+ pms_handle_rescue(e)
57
56
  flash.now[:error] = 'Comment was modified by someone else. Please review the latest changes and try again.'
58
- render(:edit, status: PMS.unprocessable_status)
57
+ render(:edit, status: PlanMyStuff.unprocessable_status)
59
58
  end
60
59
 
61
60
  private
@@ -70,13 +69,12 @@ module PlanMyStuff
70
69
  # @return [void]
71
70
  #
72
71
  def load_comment
73
- @issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
74
- @comment = PMS::Comment.find(params[:id].to_i, issue: @issue)
72
+ @issue = PlanMyStuff::Issue.find(params[:issue_id].to_i, repo: params[:repo])
73
+ @comment = PlanMyStuff::Comment.find(params[:id].to_i, issue: @issue)
75
74
  end
76
75
 
77
- # Returns true if the current user can edit the given comment.
78
- # Support users can edit any comment. Regular users can only edit
79
- # their own comments.
76
+ # Returns true if the current user can edit the given comment. Support users can edit any comment. Regular users
77
+ # can only edit their own comments.
80
78
  #
81
79
  # @param comment [PlanMyStuff::Comment]
82
80
  #
@@ -88,7 +86,7 @@ module PlanMyStuff
88
86
  user = pms_current_user
89
87
  return false if user.blank?
90
88
 
91
- comment.metadata.created_by == PMS::UserResolver.user_id(user)
89
+ comment.metadata.created_by == PlanMyStuff::UserResolver.user_id(user)
92
90
  end
93
91
 
94
92
  # @return [Boolean]