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