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

[!IMPORTANT] 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:

  • Module renamed: CommonMarker to Commonmarker
  • Methods renamed: render_html to to_html, render_doc to parse
  • Options changed from array of symbols ([:HARDBREAKS]) to nested hash (options: { render: { hardbreaks: true } })
  • Underlying engine changed from C (libcmark-gfm) to Rust (comrak)

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.

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

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 = ENV['PMS_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'
  }

  # Deferred request notifications
  config.deferred_notifier = nil
  config.deferred_email_from = 'noreply@example.com'
  config.deferred_email_to   = 'support@example.com'

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

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)
PMS::Issue.add_viewers(repo: :cms_website, number: 123, user_ids: [5, 12])
PMS::Issue.remove_viewers(repo: :cms_website, number: 123, user_ids: [5])

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

Metadata

All state lives on GitHub. Issue and comment metadata is stored as a hidden HTML comment on the first line of the body:

<!-- pms-metadata:{"gem_version":"0.0.0","app_name":"MyApp","created_at":"2026-03-24T10:00:00Z",...} -->

This is invisible when rendered on GitHub and parsed automatically by the gem into typed objects (PMS::IssueMetadata, PMS::CommentMetadata).

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

The engine provides these routes (all under the configured mount point):

Route Action
GET / Issue index
GET /issues/new New issue form
POST /issues Create issue
GET /issues/:id Issue detail with comments
GET /issues/:id/edit Edit issue form
PATCH /issues/:id Update issue
PATCH /issues/:id/close Close issue
PATCH /issues/:id/reopen Reopen issue
POST /issues/:id/comments Create comment
GET /issues/:id/comments/:id/edit Edit comment
PATCH /issues/:id/comments/:id Update comment
POST /issues/:id/labels Add label
DELETE /issues/:id/labels/:name Remove label
GET /projects Project list
GET /projects/:id Project board
POST /projects/:id/items Add item to project
PATCH /projects/:id/items/:id/move Move item
PATCH /projects/:id/items/:id/assign Assign item