plan_my_stuff
A Rails engine gem that provides a GitHub-backed ticketing and project tracking layer for internal Rails apps. Wraps GitHub Issues and Projects V2 via Octokit, handling issue CRUD, comments with visibility controls, project board management, and a configurable request gateway.
Ruby: >= 3.3 | Rails: >= 6.1, < 8 | API client: Octokit
Installation
Add to your Gemfile:
gem 'plan_my_stuff'
Then run:
bundle install
rails generate plan_my_stuff:install
This creates config/initializers/plan_my_stuff.rb with documented configuration options.
Mount the engine in config/routes.rb:
mount PlanMyStuff::Engine, at: '/tickets'
Markdown renderer
The gem supports three markdown rendering options. The chosen gem must be in your Gemfile - plan_my_stuff does not declare either as a runtime dependency.
| Option | Gemfile requirement | Config |
|---|---|---|
| commonmarker (default) | gem 'commonmarker', '~> 2.7' |
config.markdown_renderer = :commonmarker |
| redcarpet | gem 'redcarpet' |
config.markdown_renderer = :redcarpet |
| None (raw) | Nothing | config.markdown_renderer = nil |
[!NOTE] If you pick
:commonmarker, use v1.0+ — the v0.23 → v1.0 rewrite renamed the module toCommonmarker, swappedrender_htmlforto_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.
Overriding views
Copy the default views into your app for customization:
rails generate plan_my_stuff:views
Configuration
# config/initializers/plan_my_stuff.rb
PlanMyStuff.configure do |config|
# Auth (PAT from a bot account with repo + project scopes)
config.access_token = Rails.application.credentials.dig(:plan_my_stuff, :github_token)
# Organization
config.organization = 'YourOrganization'
# Named repo configs
config.repos[:marketing_website] = 'YourOrganization/MarketingWebsite'
config.repos[:cms_website] = 'YourOrganization/CMSWebsite'
config.default_repo = :cms_website
# Default project board
config.default_project_number = 123
# User class (your app's model)
config.user_class = 'User'
config.display_name_method = :full_name
config.user_id_method = :id
# Support role check (symbol or proc)
config.support_method = :support?
# Markdown rendering (:commonmarker, :redcarpet, or nil)
config.markdown_renderer = :commonmarker
# Request gateway (proc or nil; nil = always send)
config.should_send_request = nil
# Background jobs (when gateway defers a request)
config.job_classes = {
create_ticket: 'PmsCreateTicketJob',
post_comment: 'PmsPostCommentJob',
update_status: 'PmsUpdateStatusJob'
}
# Fallback actor for notification events when a caller does not pass user:
config.current_user = -> { Current.user }
# Custom fields (stored in issue/comment metadata)
config.custom_fields = {
ticket_type: { type: :string },
notification_recipients: { type: :array }
}
# App name (appears in metadata)
config.app_name = 'MyApp'
end
The PMS alias is available for brevity: PMS.configure, PMS::Issue.find, etc.
Architecture
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.
Domain class hierarchy
ApplicationRecord # includes ActiveModel::Model, Attributes, Serializers::JSON
├── Issue # GitHub Issue
├── Comment # GitHub Issue comment
├── Label # GitHub Label
├── BaseProject # shared GraphQL hydration for Projects V2
│ ├── Project # regular project board (metadata.kind == "project")
│ └── TestingProject # testing/QA board (metadata.kind == "testing")
└── BaseProjectItem # shared item behaviour
├── ProjectItem
└── TestingProjectItem # adds pass/fail sign-off logic
AR-style API
Every domain class exposes the same surface:
| Class method | Instance method | Notes |
|---|---|---|
.find(id, ...) |
#reload, #raise_if_stale! |
single read |
.list(...) |
— | list read |
.create!(attrs) |
— | write + fires *.created event |
| — | #save!, #update!(attrs) |
write + fires *.updated event |
| — | #persisted?, #new_record?, #destroyed? |
state predicates |
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.
Every mutating method accepts user: so notifications know the actor; falls back to config.current_user.call if omitted.
Usage
Issues
# Create
issue = PMS::Issue.create!(
title: 'Login page broken on Safari',
body: 'Users report blank screen after clicking login...',
repo: :cms_website,
labels: ['bug', 'app:cms_website'],
user: current_user,
add_to_project: true
)
# Find
issue = PMS::Issue.find(repo: :cms_website, number: 123)
issue.title
issue.body # body without metadata
issue. # PlanMyStuff::IssueMetadata
issue.visible_to?(user) # visibility check
issue.comments # all comments
issue.pms_comments # only PMS-created comments
# List
issues = PMS::Issue.list(repo: :cms_website, state: :open, labels: ['bug'])
# Update
issue.update!(title: 'Updated title', labels: ['bug', 'P1'])
# Close / Reopen
issue.update!(state: :closed)
issue.update!(state: :open)
# Viewer management (visibility allowlist)
issue.add_viewers(user_ids: [5, 12], user: current_user)
issue.remove_viewers(user_ids: [5], user: current_user)
Comments
# Create
comment = PMS::Comment.create!(
repo: :cms_website,
issue_number: 123,
body: 'Deployed fix to staging, please retest.',
user: current_user,
visibility: :public # or :internal (support-only)
)
# List
comments = PMS::Comment.list(repo: :cms_website, issue_number: 123)
comments = PMS::Comment.list(repo: :cms_website, issue_number: 123, pms_only: true)
comment.body # visible text
comment. # PlanMyStuff::CommentMetadata (nil for non-PMS comments)
comment.visibility # :public, :internal, or nil
comment.pms_comment? # true/false
comment.visible_to?(user)
Labels
PMS::Label.add(repo: :cms_website, issue_number: 123, labels: ['in-progress'])
PMS::Label.remove(repo: :cms_website, issue_number: 123, labels: ['triage'])
Projects
# List all projects
projects = PMS::Project.list
# View a project board
project = PMS::Project.find(14)
project.title
project.statuses # [{id: "ae429385", name: "On Deck"}, ...]
project.items # Array<PMS::ProjectItem>
# Move item to a status column
item = project.items.first
item.move_to!('In Review')
# Assign item
item.assign!('octocat')
# Add existing issue to project
PMS::Project.add_item(project_number: 14, issue_repo: :cms_website, issue_number: 123)
# Add draft item
PMS::Project.add_draft_item(project_number: 14, title: 'Draft task', body: 'Details...')
Testing tracking
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).
Create a board — either bootstrap fields from scratch, or clone the board configured at config.testing_template_project_number:
project = PMS::TestingProject.create!(
title: 'Release 2026.05 manual QA',
user: current_user,
subject_urls: ['https://github.com/Org/Repo/pull/421'],
due_date: Date.parse('2026-05-10'),
)
Per-item sign-off:
item = project.items.first
item.update_pass_mode!('all') # or 'any'
item.update_testers!(user_ids: [alice.id, bob.id])
item.update_watchers!(user_ids: [carol.id])
item.mark_passed!(user: alice) # flips to Passed when Pass Mode is satisfied
item.mark_failed!(user: alice, result_notes: 'Reproduced on Safari 17')
A board and its items are editable through the mounted UI at /testing_projects.
Notifications
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.
Event catalog
| Event | Fired from |
|---|---|
plan_my_stuff.issue.created |
Issue.create! |
plan_my_stuff.issue.updated |
issue.save! / issue.update! (any non-state change) |
plan_my_stuff.issue.closed |
issue.update!(state: :closed) |
plan_my_stuff.issue.reopened |
issue.update!(state: :open) |
plan_my_stuff.issue.viewers_added |
issue.add_viewers |
plan_my_stuff.issue.viewers_removed |
issue.remove_viewers |
plan_my_stuff.issue.approval_requested |
issue.request_approvals! |
plan_my_stuff.issue.approval_granted |
issue.approve! |
plan_my_stuff.issue.approval_revoked |
issue.revoke_approval! |
plan_my_stuff.issue.all_approved |
aggregate — fires when the set flips to fully approved |
plan_my_stuff.issue.approvals_invalidated |
aggregate — fires when a revoke (or new approver) drops the set out of fully-approved |
plan_my_stuff.comment.created |
Comment.create! |
plan_my_stuff.comment.updated |
comment.save! / comment.update! |
plan_my_stuff.label.added |
Label.add |
plan_my_stuff.label.removed |
Label.remove |
plan_my_stuff.project_item.added |
ProjectItem.create! / .add_draft_item |
plan_my_stuff.project_item.removed |
project_item.destroy! |
plan_my_stuff.project_item.assigned |
project_item.assign! |
plan_my_stuff.project_item.status_changed |
project_item.move_to! |
Payload
All events include:
:<resource>— the domain object (:issue,:comment,:project_item):user— the actor (see below):timestamp—Time.current:visibilityand:visibility_allowlist— present forIssueandCommentevents
Additional keys by event:
issue.updated/comment.updated→:changes— hash of{ attr => [old, new] }issue.viewers_added/viewers_removed→:user_idsissue.approval_requested→:approvals(array of newly-addedPMS::Approval)issue.approval_granted/approval_revoked→:approval(the flippedPMS::Approval)issue.approvals_invalidated→:trigger—:revokedor:approver_addedlabel.added/label.removed→:labelsproject_item.assigned→:assigneesproject_item.status_changed→:status,:previous_status
Actor resolution
The :user payload key is populated in this precedence:
user:kwarg passed to the mutating method (e.g.issue.update!(title: 'x', user: alice))config.current_userproc/lambda, if set (called at event time)nil
Use config.current_user = -> { Current.user } so request-scoped code doesn't have to thread user: everywhere.
Subscribing
# config/initializers/plan_my_stuff_subscribers.rb
ActiveSupport::Notifications.subscribe(/^plan_my_stuff\./) do |name, _start, _finish, _id, payload|
PmsEventMailer.dispatch(name, payload).deliver_later
end
Testing subscribers
require 'plan_my_stuff/test_helpers'
# Block-style matcher
expect {
PMS::Issue.create!(title: 'Bug', body: 'Desc', user: alice)
}.to(have_fired_event('plan_my_stuff.issue.created').with(user: alice))
# Raw capture
events = PlanMyStuff::TestHelpers::Notifications.capture do
PMS::Issue.create!(...)
end
events.first[:name] # => "plan_my_stuff.issue.created"
events.first[:payload] # => { issue:, user:, timestamp:, ... }
Approvals
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.
Writing
issue = PMS::Issue.find(123, repo: :cms_website)
# Support adds required approvers (idempotent; rejects duplicates silently)
issue.request_approvals!(user_ids: [alice.id, bob.id], user: current_user)
# An approver grants their own approval
issue.approve!(user: alice)
# An approver revokes their own approval; support may revoke on another's behalf
issue.revoke_approval!(user: alice)
issue.revoke_approval!(user: support_user, target_user_id: alice.id)
# Support removes approvers
issue.remove_approvers!(user_ids: [alice.id], user: current_user)
Reading
issue.approvers # Array<PMS::Approval> — all records (pending + approved)
issue.pending_approvals # subset still pending
issue.approvals_required? # true iff approvers.any?
issue.fully_approved? # true iff approvals_required? && pending_approvals.empty?
Pipeline gating
PMS::Pipeline::PendingApprovalsError is raised when any pending approval exists on the linked issue. Gated transitions:
Pipeline.submit!Pipeline.take!Pipeline.mark_in_review!Pipeline.request_testing!Pipeline.mark_ready_for_release!
Pipeline.remove!, start_deployment!, and complete_deployment! are intentionally NOT gated (reverse / batch / CI-driven transitions).
Events
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.
Testing
require 'plan_my_stuff/test_helpers'
# Build an issue with some approvals already in place (no API call)
issue = PlanMyStuff::TestHelpers.build_issue
PlanMyStuff::TestHelpers.stub_approvals(issue, approved: [alice], pending: [bob])
issue.fully_approved? # false
issue.pending_approvals # [PMS::Approval(user_id: bob.id, status: 'pending')]
Reminders
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.
Waiting on user
A support user enters an issue into "waiting on user" state by either:
- Posting a comment with
waiting_on_reply: true:
PMS::Comment.create!(
issue: issue,
body: "Can you confirm the date range?",
user: current_user,
waiting_on_reply: true,
)
- Clicking the Mark waiting button on the mounted issue show page.
Both paths add the waiting-on-user label, set issue.metadata.waiting_on_user_at = now, and schedule the first reminder.
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.
Waiting on approval
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.
Reminder schedule
Reminders fire at specific day counts since waiting started:
config.reminder_days = [1, 3, 7, 10, 14, 18] # default
Override per-issue:
issue..reminder_days = [2, 5, 9]
issue.save!
Each reminder emits plan_my_stuff.issue.reminder_due with payload:
| Field | Type | Notes |
|---|---|---|
issue |
PMS::Issue |
the waiting issue |
waiting_kind |
:user or :approval |
which clock drove the reminder |
days_waiting |
Integer | days since the waiting clock started |
reminder_day |
Integer | the matched entry in reminder_days |
last_activity_at |
Time | last comment or issue.updated_at (informational) |
pending_approvers |
Array<User> |
resolved via UserResolver; only for :approval |
Subscribe in your app to deliver (email, Slack, etc.):
ActiveSupport::Notifications.subscribe('plan_my_stuff.issue.reminder_due') do |event|
issue = event.payload[:issue]
case event.payload[:waiting_kind]
when :user then NotifyUserMailer.reminder(issue).deliver_later
when :approval then NotifyApproversMailer.reminder(issue, event.payload[:pending_approvers]).deliver_later
end
end
Inactivity auto-close
When an issue's days_waiting reaches config.inactivity_close_days (default 30), the sweep:
- Closes the issue
- Sets
issue.metadata.closed_by_inactivity = true - Adds the
user-inactivelabel (configurable viaconfig.user_inactive_label) - Emits
plan_my_stuff.issue.closed_inactive(withreason: :inactivity) — the regularissue.closedevent is suppressed so subscribers listening to "close" don't double-fire for automated closes
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.
Scheduling the sweep
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:
# Enqueue one job per configured repo:
rake plan_my_stuff:reminders:sweep
# Or target a single repo:
rake plan_my_stuff:reminders:sweep REPO=element
You can also schedule from Ruby if you prefer:
PlanMyStuff::RemindersSweepJob.requeue(:your_repo_key) # schedules for next_run
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.
Override the cadence by subclassing:
class MyRemindersJob < PlanMyStuff::RemindersSweepJob
def self.next_run
4.hours.from_now.utc # run every 4 hours
end
end
Disabling
config.reminders_enabled = false
The sweep becomes a no-op but still self-requeues so re-enabling doesn't require manual intervention.
Auto-archiving
Closed PMS issues that have aged past config.archive_closed_after_days (default 90) are archived by the daily sweep. Archive means:
- Tag the issue with
config.archived_label(defaultarchived). - Lock the conversation on GitHub (no new comments).
- Remove the issue from every Projects V2 board it sits on.
- Stamp
metadata.archived_at. - Emit
plan_my_stuff.issue.archivedwithreason: :aged_closed.
The sweep runs inside PlanMyStuff::RemindersSweepJob alongside the follow-up reminders sweep — same cadence, same rake task.
Exclusions
- Non-PMS issues (no
pms-metadatamarker) are skipped — the gem only archives what it created. - Inactivity-closed issues (
metadata.closed_by_inactivity == true) are skipped —T-051already handled the close. - Already-archived issues (
metadata.archived_atset or archived label present) are skipped.
Manual archive
issue = PMS::Issue.find(123)
issue.archive!
No-op if the issue is open, already archived, or already carries the archived label.
Config
config.archiving_enabled = true
config.archive_closed_after_days = 90
config.archived_label = 'archived'
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.
Subscribing
ActiveSupport::Notifications.subscribe('plan_my_stuff.issue.archived') do |event|
issue = event.payload[:issue]
ArchiveMailer.notify(issue).deliver_later
end
Disabling
config.archiving_enabled = false
Reminders keep running; only the archive pass is suppressed.
Caching
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").
| Cached | Method |
|---|---|
Issue.find, Issue.list |
ETag |
Comment.find, Comment.list |
ETag |
Project.find, ProjectItem reads |
not cached (GraphQL, no HTTP ETag — deferred) |
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.
Configuration
config.cache_enabled = true # default; set false to bypass entirely
config.cache_version = 'v2' # opaque string baked into every cache key;
# bump to orphan all cached entries after deploy
Cache keys include both the gem's CACHE_VERSION ("v1") and config.cache_version, so gem upgrades and app-side invalidations are independent.
Metadata
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:
<!-- pms-metadata:{"schema_version":1,"gem_version":"0.0.0","app_name":"MyApp",...} -->
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.
Metadata classes
| Class | Stored on | Notable fields |
|---|---|---|
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 |
CommentMetadata |
Comment body | issue_body (marks the issue's body-comment), custom_fields |
ProjectMetadata |
Project readme | kind: "project" |
TestingProjectMetadata |
Testing project readme | kind: "testing", subject_urls, due_date, deadline_miss_reason |
ProjectItemMetadata |
— | minimal; reserved for future fields |
All metadata classes share schema_version, gem_version, app_name, created_by, visibility, and custom_fields via BaseMetadata.
Custom fields
custom_fields is the escape hatch for app-specific data. Declare a schema in the initializer:
PlanMyStuff.configure do |config|
# Shared across everything that has custom_fields
config.custom_fields = {
ticket_type: { type: :string, required: true },
}
# Resource-specific (merged on top of shared)
config.issue_custom_fields = { notification_recipients: { type: :array } }
config.comment_custom_fields = { internal_note: { type: :boolean } }
config.project_custom_fields = { team: { type: :string } }
config.testing_custom_fields = { test_plan_url: { type: :string } }
end
Access by name or hash:
issue..custom_fields.ticket_type # method-style
issue..custom_fields[:ticket_type] = 'bug'
issue.save! # validates; raises ActiveModel::ValidationError
Release pipeline
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.
Statuses
PMS::Pipeline::Status::ALL (in order): Submitted → Started → In Review → Testing → Ready for Release → Release in Progress → Completed.
Display names can be overridden in the consuming app via config.pipeline_statuses; the constants above remain the internal identifiers.
Transitions
# Add an issue to the pipeline board
item = PMS::Pipeline.submit!(issue, assignee: current_user.github_login)
# Forward transitions
PMS::Pipeline.take!(item) # Started
PMS::Pipeline.mark_in_review!(item) # In Review
PMS::Pipeline.request_testing!(item) # Testing
PMS::Pipeline.mark_ready_for_release!(item) # Ready for Release
# Deployment-driven transitions
PMS::Pipeline.start_deployment!(commit_sha: 'abc123…') # every Ready item whose issue is in the commit → Release in Progress
PMS::Pipeline.complete_deployment!(item, deployment_id: 42) # Completed (when issue.metadata.auto_complete)
# Remove from pipeline (deletes the project item)
PMS::Pipeline.remove!(item)
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.
Webhook-driven automation
The engine mounts two webhook endpoints when the pipeline is enabled:
POST /webhooks/github— takes GitHubpull_requestevents. Onclosedagainst a tracked branch, the gem callsIssueLinkerto extract#123references from the PR body and commit messages, then transitions each linked issue (e.g. merged to main →start_deployment!).POST /webhooks/aws— takes CodeDeploy/SNS lifecycle events. On a successful deployment, the gem flips matching items toCompleted.
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.
"Take" button
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).
Verify setup
rails plan_my_stuff:verify
Checks token validity, org access, repo access, and project access.
Testing
The gem provides test helpers for consuming apps:
# spec/spec_helper.rb (or rails_helper.rb)
require 'plan_my_stuff/test_helpers'
RSpec.configure do |config|
config.include PlanMyStuff::TestHelpers
end
Available in tests:
PlanMyStuff.test_mode! # stubs all API calls
build_issue(title: 'Test issue')
build_comment(body: 'Test comment')
build_project(title: 'Test board')
expect_pms_issue_created(title: 'Test issue')
expect_pms_comment_created(body: 'Test comment')
expect_pms_item_moved(status: 'In Review')
Engine routes
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.
Issues
| Route | Action |
|---|---|
GET /issues, GET /issues/new, POST /issues |
index, new, create |
GET /issues/:id, GET /issues/:id/edit, PATCH /issues/:id |
show, edit, update |
POST /issues/:issue_id/closure / DELETE …/closure |
close / reopen |
POST /issues/:issue_id/waiting / DELETE …/waiting |
mark / clear waiting-on-user |
POST /issues/:issue_id/viewers / DELETE …/viewers/:id |
add / remove viewer |
POST /issues/:issue_id/take (pipeline) |
Take button → Pipeline.take! |
POST /issues/:issue_id/comments, GET …/comments/:id/edit, PATCH …/comments/:id |
create / edit / update comment |
POST /issues/:issue_id/labels / DELETE …/labels/:id |
add / remove label |
POST /issues/:issue_id/links / DELETE …/links/:id |
link / unlink related issue |
POST /issues/:issue_id/approvals, PATCH …/approvals/:id, DELETE …/approvals/:id |
request / grant or revoke / remove approver |
Projects
| Route | Action |
|---|---|
GET /projects, GET /projects/new, POST /projects |
index, new, create |
GET /projects/:id, GET /projects/:id/edit, PATCH /projects/:id |
show, edit, update |
POST /projects/:project_id/items |
add item |
PATCH /projects/:project_id/items/:item_id/status |
move item to a status column |
PATCH /projects/:project_id/items/:item_id/assignment / DELETE …/assignment |
assign / unassign |
Testing projects
| Route | Action |
|---|---|
GET /testing_projects/new, POST /testing_projects |
new, create |
GET /testing_projects/:id, GET …/:id/edit, PATCH …/:id |
show, edit, update |
GET /testing_projects/:testing_project_id/items/new, POST …/items |
new, create item |
PATCH /testing_projects/:testing_project_id/items/:item_id/status |
move item |
GET …/items/:item_id/result/new, POST …/items/:item_id/result |
record pass/fail result |
Webhooks (pipeline only)
| Route | Action |
|---|---|
POST /webhooks/github |
GitHub PR / projects_v2_item events |
POST /webhooks/aws |
AWS CodeDeploy / SNS lifecycle events |
Controller overrides
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.
# app/controllers/my_app/issues_controller.rb
class MyApp::IssuesController < PlanMyStuff::IssuesController
before_action :authenticate_user!
before_action :authorize_ticket_access
end
# config/initializers/plan_my_stuff.rb
PlanMyStuff.configure do |config|
config.controllers['issues'] = 'my_app/issues'
end
Overridable keys (see PlanMyStuff::Configuration::DEFAULT_CONTROLLERS):
issues issues/closures
comments issues/viewers
labels issues/takes
projects issues/waitings
project_items issues/links
testing_projects issues/approvals
testing_project_items project_items/statuses
webhooks/github project_items/assignments
webhooks/aws testing_project_items/results