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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +595 -0
- data/CONFIGURATION.md +487 -0
- data/README.md +612 -88
- data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
- data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
- data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
- data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
- data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
- data/app/jobs/plan_my_stuff/application_job.rb +8 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
- data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
- data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
- data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
- data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
- data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +43 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
- data/lib/plan_my_stuff/application_record.rb +158 -1
- data/lib/plan_my_stuff/approval.rb +88 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +12 -0
- data/lib/plan_my_stuff/attachment.rb +83 -0
- data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
- data/lib/plan_my_stuff/base_metadata.rb +25 -28
- data/lib/plan_my_stuff/base_project.rb +502 -0
- data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
- data/lib/plan_my_stuff/base_project_item.rb +588 -0
- data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
- data/lib/plan_my_stuff/cache.rb +197 -0
- data/lib/plan_my_stuff/client.rb +139 -64
- data/lib/plan_my_stuff/comment.rb +225 -100
- data/lib/plan_my_stuff/comment_metadata.rb +68 -5
- data/lib/plan_my_stuff/configuration.rb +459 -28
- data/lib/plan_my_stuff/custom_fields.rb +96 -12
- data/lib/plan_my_stuff/engine.rb +14 -2
- data/lib/plan_my_stuff/errors.rb +65 -5
- data/lib/plan_my_stuff/graphql/queries.rb +454 -0
- data/lib/plan_my_stuff/issue.rb +1097 -166
- data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
- data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
- data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
- data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
- data/lib/plan_my_stuff/issue_field.rb +126 -0
- data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
- data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
- data/lib/plan_my_stuff/issue_metadata.rb +132 -21
- data/lib/plan_my_stuff/label.rb +100 -13
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/markdown.rb +13 -7
- data/lib/plan_my_stuff/metadata_parser.rb +51 -12
- data/lib/plan_my_stuff/notifications.rb +148 -0
- data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +40 -0
- data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
- data/lib/plan_my_stuff/pipeline.rb +310 -0
- data/lib/plan_my_stuff/project.rb +63 -465
- data/lib/plan_my_stuff/project_item.rb +3 -409
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +47 -0
- data/lib/plan_my_stuff/reminders/closer.rb +70 -0
- data/lib/plan_my_stuff/reminders/fire.rb +129 -0
- data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
- data/lib/plan_my_stuff/reminders.rb +12 -0
- data/lib/plan_my_stuff/repo.rb +145 -0
- data/lib/plan_my_stuff/test_helpers.rb +265 -25
- data/lib/plan_my_stuff/testing_project.rb +292 -0
- data/lib/plan_my_stuff/testing_project_item.rb +218 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +24 -3
- data/lib/plan_my_stuff/verifier.rb +10 -0
- data/lib/plan_my_stuff/version.rb +2 -2
- data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
- data/lib/plan_my_stuff.rb +55 -20
- data/lib/tasks/plan_my_stuff.rake +331 -0
- 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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
config.default_project_number = 123
|
|
73
|
+
The `PMS` alias is available for brevity: `PMS.configure`, `PMS::Issue.find`, etc.
|
|
80
74
|
|
|
81
|
-
|
|
82
|
-
config.user_class = 'User'
|
|
83
|
-
config.display_name_method = :full_name
|
|
84
|
-
config.user_id_method = :id
|
|
75
|
+
## Architecture
|
|
85
76
|
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
config.markdown_renderer = :commonmarker
|
|
79
|
+
### Domain class hierarchy
|
|
91
80
|
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
config.job_classes = {
|
|
97
|
-
create_ticket: 'PmsCreateTicketJob',
|
|
98
|
-
post_comment: 'PmsPostCommentJob',
|
|
99
|
-
update_status: 'PmsUpdateStatusJob'
|
|
100
|
-
}
|
|
94
|
+
### AR-style API
|
|
101
95
|
|
|
102
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
126
|
+
issue = PMS::Issue.find(123, repo: :cms_website)
|
|
137
127
|
issue.title
|
|
138
128
|
issue.body # body without metadata
|
|
139
|
-
issue.metadata #
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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(
|
|
173
|
-
comments = PMS::Comment.list(
|
|
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 #
|
|
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(
|
|
186
|
-
PMS::Label.remove(
|
|
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::
|
|
234
|
+
PMS::ProjectItem.create!(issue, project_number: 14)
|
|
210
235
|
|
|
211
236
|
# Add draft item
|
|
212
|
-
PMS::
|
|
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.
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
267
|
-
| `GET /issues/
|
|
268
|
-
| `POST /issues` |
|
|
269
|
-
| `
|
|
270
|
-
| `
|
|
271
|
-
| `
|
|
272
|
-
| `
|
|
273
|
-
| `
|
|
274
|
-
| `POST /issues/:
|
|
275
|
-
| `
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
|
280
|
-
|
|
281
|
-
| `
|
|
282
|
-
| `
|
|
283
|
-
| `
|
|
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
|
|