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 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.

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)
  • :timestampTime.current
  • :visibility and :visibility_allowlist — present for Issue and Comment events

Additional keys by event:

  • issue.updated / comment.updated:changes — hash of { attr => [old, new] }
  • issue.viewers_added / viewers_removed:user_ids
  • issue.approval_requested:approvals (array of newly-added PMS::Approval)
  • issue.approval_granted / approval_revoked:approval (the flipped PMS::Approval)
  • issue.approvals_invalidated:trigger:revoked or :approver_added
  • label.added / label.removed:labels
  • project_item.assigned:assignees
  • project_item.status_changed:status, :previous_status

Actor resolution

The :user payload key is populated in this precedence:

  1. user: kwarg passed to the mutating method (e.g. issue.update!(title: 'x', user: alice))
  2. config.current_user proc/lambda, if set (called at event time)
  3. 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:

  1. 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,
   )
  1. 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-inactive label (configurable via config.user_inactive_label)
  • 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

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:

  1. Tag the issue with config.archived_label (default archived).
  2. Lock the conversation on GitHub (no new comments).
  3. Remove the issue from every Projects V2 board it sits on.
  4. Stamp metadata.archived_at.
  5. Emit plan_my_stuff.issue.archived with reason: :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-metadata marker) are skipped — the gem only archives what it created.
  • Inactivity-closed issues (metadata.closed_by_inactivity == true) are skipped — T-051 already handled the close.
  • Already-archived issues (metadata.archived_at set 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): SubmittedStartedIn ReviewTestingReady for ReleaseRelease in ProgressCompleted.

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.)

# 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 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!).
  • POST /webhooks/aws — takes CodeDeploy/SNS lifecycle events. On a successful deployment, the gem flips matching items to Completed.

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