plan_my_stuff 0.1.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +28 -0
  3. data/README.md +284 -0
  4. data/app/controllers/plan_my_stuff/application_controller.rb +76 -0
  5. data/app/controllers/plan_my_stuff/comments_controller.rb +82 -0
  6. data/app/controllers/plan_my_stuff/issues_controller.rb +145 -0
  7. data/app/controllers/plan_my_stuff/labels_controller.rb +30 -0
  8. data/app/controllers/plan_my_stuff/project_items_controller.rb +93 -0
  9. data/app/controllers/plan_my_stuff/projects_controller.rb +17 -0
  10. data/app/views/plan_my_stuff/comments/edit.html.erb +16 -0
  11. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +32 -0
  12. data/app/views/plan_my_stuff/issues/edit.html.erb +12 -0
  13. data/app/views/plan_my_stuff/issues/index.html.erb +37 -0
  14. data/app/views/plan_my_stuff/issues/new.html.erb +7 -0
  15. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +41 -0
  16. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +23 -0
  17. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +32 -0
  18. data/app/views/plan_my_stuff/issues/show.html.erb +58 -0
  19. data/app/views/plan_my_stuff/projects/index.html.erb +13 -0
  20. data/app/views/plan_my_stuff/projects/show.html.erb +101 -0
  21. data/config/routes.rb +25 -0
  22. data/lib/generators/plan_my_stuff/install/install_generator.rb +38 -0
  23. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +106 -0
  24. data/lib/generators/plan_my_stuff/views/views_generator.rb +22 -0
  25. data/lib/plan_my_stuff/application_record.rb +39 -0
  26. data/lib/plan_my_stuff/base_metadata.rb +136 -0
  27. data/lib/plan_my_stuff/client.rb +143 -0
  28. data/lib/plan_my_stuff/comment.rb +360 -0
  29. data/lib/plan_my_stuff/comment_metadata.rb +56 -0
  30. data/lib/plan_my_stuff/configuration.rb +139 -0
  31. data/lib/plan_my_stuff/custom_fields.rb +65 -0
  32. data/lib/plan_my_stuff/engine.rb +11 -0
  33. data/lib/plan_my_stuff/errors.rb +87 -0
  34. data/lib/plan_my_stuff/issue.rb +486 -0
  35. data/lib/plan_my_stuff/issue_metadata.rb +111 -0
  36. data/lib/plan_my_stuff/label.rb +59 -0
  37. data/lib/plan_my_stuff/markdown.rb +83 -0
  38. data/lib/plan_my_stuff/metadata_parser.rb +53 -0
  39. data/lib/plan_my_stuff/project.rb +504 -0
  40. data/lib/plan_my_stuff/project_item.rb +414 -0
  41. data/lib/plan_my_stuff/test_helpers.rb +501 -0
  42. data/lib/plan_my_stuff/user_resolver.rb +61 -0
  43. data/lib/plan_my_stuff/verifier.rb +102 -0
  44. data/lib/plan_my_stuff/version.rb +19 -0
  45. data/lib/plan_my_stuff.rb +69 -0
  46. data/lib/tasks/plan_my_stuff.rake +23 -0
  47. metadata +126 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 03731d4c3b552639604aa37465eb9f2246eb6077ec40144b218b4afa2aefdc01
4
+ data.tar.gz: 0277ebbdc6a0f783c90cb1bcf2d73c6dfd79447ad52f1df8d547d217cf80e1bf
5
+ SHA512:
6
+ metadata.gz: 3897dad435fd7b13780d3ac6ac3e25f9d15e49f58e8517c05ff6b28b78b7efd16216e4c9dbf0275afadf842821a9a006dd0e1c86b862e10927e197f190029f6a
7
+ data.tar.gz: 1a973153e39fc469580eac16dca37debb9780416b1cc7b50f9dffd440a6f64f4383fca30f24e72fdc02173c5875938c9181febd0226de8ecc8023c9907ac10db
data/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Brands Insurance Agency, Inc.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,284 @@
1
+ # plan_my_stuff
2
+
3
+ <!-- Badges (update once CI and RubyGems are configured) -->
4
+ <!-- ![CI](https://github.com/BrandsInsurance/PlanMyStuff/actions/workflows/ci.yml/badge.svg) -->
5
+ <!-- ![Gem Version](https://img.shields.io/gem/v/plan_my_stuff) -->
6
+ <!-- ![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-red) -->
7
+
8
+ 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.
9
+
10
+ **Ruby:** >= 3.3 | **Rails:** >= 6.1, < 8 | **API client:** Octokit
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
+ ## Installation
22
+
23
+ Add to your Gemfile:
24
+
25
+ ```ruby
26
+ gem 'plan_my_stuff'
27
+ ```
28
+
29
+ Then run:
30
+
31
+ ```bash
32
+ bundle install
33
+ rails generate plan_my_stuff:install
34
+ ```
35
+
36
+ This creates `config/initializers/plan_my_stuff.rb` with documented configuration options.
37
+
38
+ Mount the engine in `config/routes.rb`:
39
+
40
+ ```ruby
41
+ mount PlanMyStuff::Engine, at: '/tickets'
42
+ ```
43
+
44
+ ### Markdown renderer
45
+
46
+ 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.
47
+
48
+ | Option | Gemfile requirement | Config |
49
+ |---|---|---|
50
+ | commonmarker (default) | `gem 'commonmarker', '~> 2.7'` | `config.markdown_renderer = :commonmarker` |
51
+ | redcarpet | `gem 'redcarpet'` | `config.markdown_renderer = :redcarpet` |
52
+ | None (raw) | Nothing | `config.markdown_renderer = nil` |
53
+
54
+ ### Overriding views
55
+
56
+ Copy the default views into your app for customization:
57
+
58
+ ```bash
59
+ rails generate plan_my_stuff:views
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ ```ruby
65
+ # config/initializers/plan_my_stuff.rb
66
+ PlanMyStuff.configure do |config|
67
+ # Auth (PAT from a bot account with repo + project scopes)
68
+ config.access_token = ENV['PMS_GITHUB_TOKEN']
69
+
70
+ # Organization
71
+ config.organization = 'YourOrganization'
72
+
73
+ # Named repo configs
74
+ config.repos[:marketing_website] = 'YourOrganization/MarketingWebsite'
75
+ config.repos[:cms_website] = 'YourOrganization/CMSWebsite'
76
+ config.default_repo = :cms_website
77
+
78
+ # Default project board
79
+ config.default_project_number = 123
80
+
81
+ # User class (your app's model)
82
+ config.user_class = 'User'
83
+ config.display_name_method = :full_name
84
+ config.user_id_method = :id
85
+
86
+ # Support role check (symbol or proc)
87
+ config.support_method = :support?
88
+
89
+ # Markdown rendering (:commonmarker, :redcarpet, or nil)
90
+ config.markdown_renderer = :commonmarker
91
+
92
+ # Request gateway (proc or nil; nil = always send)
93
+ config.should_send_request = nil
94
+
95
+ # Background jobs (when gateway defers a request)
96
+ config.job_classes = {
97
+ create_ticket: 'PmsCreateTicketJob',
98
+ post_comment: 'PmsPostCommentJob',
99
+ update_status: 'PmsUpdateStatusJob'
100
+ }
101
+
102
+ # Deferred request notifications
103
+ config.deferred_notifier = nil
104
+ config.deferred_email_from = 'noreply@example.com'
105
+ config.deferred_email_to = 'support@example.com'
106
+
107
+ # Custom fields (stored in issue/comment metadata)
108
+ config.custom_fields = {
109
+ ticket_type: { type: :string },
110
+ notification_recipients: { type: :array }
111
+ }
112
+
113
+ # App name (appears in metadata)
114
+ config.app_name = 'MyApp'
115
+ end
116
+ ```
117
+
118
+ The `PMS` alias is available for brevity: `PMS.configure`, `PMS::Issue.find`, etc.
119
+
120
+ ## Usage
121
+
122
+ ### Issues
123
+
124
+ ```ruby
125
+ # Create
126
+ issue = PMS::Issue.create!(
127
+ title: 'Login page broken on Safari',
128
+ body: 'Users report blank screen after clicking login...',
129
+ repo: :cms_website,
130
+ labels: ['bug', 'app:cms_website'],
131
+ user: current_user,
132
+ add_to_project: true
133
+ )
134
+
135
+ # Find
136
+ issue = PMS::Issue.find(repo: :cms_website, number: 123)
137
+ issue.title
138
+ issue.body # body without metadata
139
+ issue.metadata # PlanMyStuff::IssueMetadata
140
+ issue.visible_to?(user) # visibility check
141
+ issue.comments # all comments
142
+ issue.pms_comments # only PMS-created comments
143
+
144
+ # List
145
+ issues = PMS::Issue.list(repo: :cms_website, state: :open, labels: ['bug'])
146
+
147
+ # Update
148
+ issue.update!(title: 'Updated title', labels: ['bug', 'P1'])
149
+
150
+ # Close / Reopen
151
+ issue.update!(state: :closed)
152
+ issue.update!(state: :open)
153
+
154
+ # Viewer management (visibility allowlist)
155
+ PMS::Issue.add_viewers(repo: :cms_website, number: 123, user_ids: [5, 12])
156
+ PMS::Issue.remove_viewers(repo: :cms_website, number: 123, user_ids: [5])
157
+ ```
158
+
159
+ ### Comments
160
+
161
+ ```ruby
162
+ # Create
163
+ comment = PMS::Comment.create!(
164
+ repo: :cms_website,
165
+ issue_number: 123,
166
+ body: 'Deployed fix to staging, please retest.',
167
+ user: current_user,
168
+ visibility: :public # or :internal (support-only)
169
+ )
170
+
171
+ # List
172
+ comments = PMS::Comment.list(repo: :cms_website, issue_number: 123)
173
+ comments = PMS::Comment.list(repo: :cms_website, issue_number: 123, pms_only: true)
174
+
175
+ comment.body # visible text
176
+ comment.metadata # PlanMyStuff::CommentMetadata (nil for non-PMS comments)
177
+ comment.visibility # :public, :internal, or nil
178
+ comment.pms_comment? # true/false
179
+ comment.visible_to?(user)
180
+ ```
181
+
182
+ ### Labels
183
+
184
+ ```ruby
185
+ PMS::Label.add(repo: :cms_website, issue_number: 123, labels: ['in-progress'])
186
+ PMS::Label.remove(repo: :cms_website, issue_number: 123, labels: ['triage'])
187
+ ```
188
+
189
+ ### Projects
190
+
191
+ ```ruby
192
+ # List all projects
193
+ projects = PMS::Project.list
194
+
195
+ # View a project board
196
+ project = PMS::Project.find(14)
197
+ project.title
198
+ project.statuses # [{id: "ae429385", name: "On Deck"}, ...]
199
+ project.items # Array<PMS::ProjectItem>
200
+
201
+ # Move item to a status column
202
+ item = project.items.first
203
+ item.move_to!('In Review')
204
+
205
+ # Assign item
206
+ item.assign!('octocat')
207
+
208
+ # Add existing issue to project
209
+ PMS::Project.add_item(project_number: 14, issue_repo: :cms_website, issue_number: 123)
210
+
211
+ # Add draft item
212
+ PMS::Project.add_draft_item(project_number: 14, title: 'Draft task', body: 'Details...')
213
+ ```
214
+
215
+ ## Metadata
216
+
217
+ All state lives on GitHub. Issue and comment metadata is stored as a hidden HTML comment on the first line of the body:
218
+
219
+ ```html
220
+ <!-- pms-metadata:{"gem_version":"0.0.0","app_name":"MyApp","created_at":"2026-03-24T10:00:00Z",...} -->
221
+ ```
222
+
223
+ This is invisible when rendered on GitHub and parsed automatically by the gem into typed objects (`PMS::IssueMetadata`, `PMS::CommentMetadata`).
224
+
225
+ ## Verify setup
226
+
227
+ ```bash
228
+ rails plan_my_stuff:verify
229
+ ```
230
+
231
+ Checks token validity, org access, repo access, and project access.
232
+
233
+ ## Testing
234
+
235
+ The gem provides test helpers for consuming apps:
236
+
237
+ ```ruby
238
+ # spec/spec_helper.rb (or rails_helper.rb)
239
+ require 'plan_my_stuff/test_helpers'
240
+
241
+ RSpec.configure do |config|
242
+ config.include PlanMyStuff::TestHelpers
243
+ end
244
+ ```
245
+
246
+ Available in tests:
247
+
248
+ ```ruby
249
+ PlanMyStuff.test_mode! # stubs all API calls
250
+
251
+ build_issue(title: 'Test issue')
252
+ build_comment(body: 'Test comment')
253
+ build_project(title: 'Test board')
254
+
255
+ expect_pms_issue_created(title: 'Test issue')
256
+ expect_pms_comment_created(body: 'Test comment')
257
+ expect_pms_item_moved(status: 'In Review')
258
+ ```
259
+
260
+ ## Engine routes
261
+
262
+ The engine provides these routes (all under the configured mount point):
263
+
264
+ | Route | Action |
265
+ |---|---|
266
+ | `GET /` | Issue index |
267
+ | `GET /issues/new` | New issue form |
268
+ | `POST /issues` | Create issue |
269
+ | `GET /issues/:id` | Issue detail with comments |
270
+ | `GET /issues/:id/edit` | Edit issue form |
271
+ | `PATCH /issues/:id` | Update issue |
272
+ | `PATCH /issues/:id/close` | Close issue |
273
+ | `PATCH /issues/:id/reopen` | Reopen issue |
274
+ | `POST /issues/:id/comments` | Create comment |
275
+ | `GET /issues/:id/comments/:id/edit` | Edit comment |
276
+ | `PATCH /issues/:id/comments/:id` | Update comment |
277
+ | `POST /issues/:id/labels` | Add label |
278
+ | `DELETE /issues/:id/labels/:name` | Remove label |
279
+ | `GET /projects` | Project list |
280
+ | `GET /projects/:id` | Project board |
281
+ | `POST /projects/:id/items` | Add item to project |
282
+ | `PATCH /projects/:id/items/:id/move` | Move item |
283
+ | `PATCH /projects/:id/items/:id/assign` | Assign item |
284
+
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ class ApplicationController < ::ApplicationController
5
+ protect_from_forgery with: :exception
6
+ helper Rails.application.routes.url_helpers
7
+
8
+ before_action :authenticate_pms_user!
9
+
10
+ private
11
+
12
+ # Hook for consuming app authentication. Override this method in your
13
+ # ApplicationController, or configure via PlanMyStuff.configure:
14
+ #
15
+ # PlanMyStuff.configure do |config|
16
+ # config.authenticate_with do
17
+ # redirect_to login_path unless current_user
18
+ # end
19
+ # end
20
+ #
21
+ def authenticate_pms_user!
22
+ pms_auth = PlanMyStuff.configuration.authenticate_with
23
+ instance_exec(&pms_auth) if pms_auth
24
+ end
25
+
26
+ # Returns the current user for PMS visibility checks.
27
+ # Delegates to the consuming app's current_user method.
28
+ #
29
+ # @return [Object, nil]
30
+ #
31
+ def pms_current_user
32
+ return current_user if respond_to?(:current_user, true)
33
+ end
34
+
35
+ # @return [Boolean]
36
+ def support_user?
37
+ pms_current_user.present? && PMS::UserResolver.support?(pms_current_user)
38
+ end
39
+
40
+ # Redirects non-support users back with an error.
41
+ #
42
+ # @param path [String] redirect destination
43
+ # @param message [String]
44
+ #
45
+ # @return [void]
46
+ #
47
+ def redirect_to_unauthorized(path, message: 'You do not have permission to perform this action.')
48
+ flash[:error] = message
49
+ redirect_to(path)
50
+ end
51
+
52
+ # Splits a comma-separated labels string into an array.
53
+ #
54
+ # @param labels_string [String, nil]
55
+ #
56
+ # @return [Array<String>]
57
+ #
58
+ def parse_labels(labels_string)
59
+ return [] if labels_string.blank?
60
+
61
+ labels_string.split(',').filter_map { |l| l.strip.presence }
62
+ end
63
+
64
+ # Parses a comma-separated string of viewer IDs into an array of integers.
65
+ #
66
+ # @param ids_string [String, nil]
67
+ #
68
+ # @return [Array<Integer>]
69
+ #
70
+ def parse_viewer_ids(ids_string)
71
+ return [] if ids_string.blank?
72
+
73
+ ids_string.split(',').filter_map { |id| id.strip.presence&.to_i }
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ class CommentsController < ApplicationController
5
+ # POST /issues/:issue_id/comments
6
+ def create
7
+ @issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo]&.to_sym)
8
+
9
+ PMS::Comment.create!(
10
+ issue: @issue,
11
+ body: comment_params[:body],
12
+ user: pms_current_user,
13
+ visibility: comment_params[:visibility]&.to_sym || :public,
14
+ )
15
+
16
+ flash[:success] = 'Comment was successfully created.'
17
+ redirect_to(plan_my_stuff.issue_path(@issue.number))
18
+ end
19
+
20
+ # GET /issues/:issue_id/comments/:id/edit
21
+ def edit
22
+ load_comment
23
+ return unless @comment
24
+
25
+ @support_user = support_user?
26
+ return redirect_to_unauthorized(plan_my_stuff.issue_path(@issue.number)) unless can_edit?(@comment)
27
+ end
28
+
29
+ # PATCH/PUT /issues/:issue_id/comments/:id
30
+ def update
31
+ load_comment
32
+ return unless @comment
33
+
34
+ @support_user = support_user?
35
+ return redirect_to_unauthorized(plan_my_stuff.issue_path(@issue.number)) unless can_edit?(@comment)
36
+
37
+ update_attrs = { body: comment_params[:body] }
38
+ update_attrs[:visibility] = comment_params[:visibility].to_sym if @support_user && comment_params[:visibility]
39
+
40
+ @comment.update!(**update_attrs)
41
+
42
+ flash[:success] = 'Comment was successfully updated.'
43
+ redirect_to(plan_my_stuff.issue_path(@issue.number))
44
+ rescue PMS::StaleObjectError
45
+ flash.now[:error] = 'Comment was modified by someone else. Please review the latest changes and try again.'
46
+ render(:edit, status: PMS.unprocessable_status)
47
+ end
48
+
49
+ private
50
+
51
+ # @return [ActionController::Parameters]
52
+ def comment_params
53
+ params.require(:comment).permit(:body, :visibility)
54
+ end
55
+
56
+ # Loads the issue and comment from params.
57
+ #
58
+ # @return [void]
59
+ #
60
+ def load_comment
61
+ @issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo]&.to_sym)
62
+ @comment = PMS::Comment.find(params[:id].to_i, issue: @issue)
63
+ end
64
+
65
+ # Returns true if the current user can edit the given comment.
66
+ # Support users can edit any comment. Regular users can only edit
67
+ # their own comments.
68
+ #
69
+ # @param comment [PlanMyStuff::Comment]
70
+ #
71
+ # @return [Boolean]
72
+ #
73
+ def can_edit?(comment)
74
+ return true if support_user?
75
+
76
+ user = pms_current_user
77
+ return false if user.blank?
78
+
79
+ comment.metadata.created_by == PMS::UserResolver.user_id(user)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ class IssuesController < ApplicationController
5
+ # GET /issues
6
+ def index
7
+ @page = (params[:page] || 1).to_i
8
+ @per_page = (params[:per_page] || 25).to_i
9
+ @state = (params[:state] || 'open').to_sym
10
+ @labels = params[:labels].present? ? Array.wrap(params[:labels]) : []
11
+ @repo = params[:repo]&.to_sym
12
+
13
+ @issues = PMS::Issue.list(
14
+ repo: @repo,
15
+ state: @state,
16
+ labels: @labels,
17
+ page: @page,
18
+ per_page: @per_page,
19
+ )
20
+ end
21
+
22
+ # GET /issues/new
23
+ def new
24
+ @issue = PMS::Issue.new
25
+ @support_user = support_user?
26
+ end
27
+
28
+ # POST /issues
29
+ def create
30
+ @issue = PMS::Issue.create!(
31
+ title: issue_params[:title],
32
+ body: issue_params[:body],
33
+ labels: parse_labels(issue_params[:labels]),
34
+ user: pms_current_user,
35
+ )
36
+
37
+ flash[:success] = 'Issue was successfully created.'
38
+ redirect_to(plan_my_stuff.issue_path(@issue.number))
39
+ rescue PMS::ValidationError => e
40
+ @issue = PMS::Issue.new(title: issue_params[:title], body: issue_params[:body])
41
+ @support_user = support_user?
42
+ flash.now[:error] = e.message
43
+ render(:new, status: PMS.unprocessable_status)
44
+ end
45
+
46
+ # GET /issues/:id
47
+ def show
48
+ @issue = PMS::Issue.find(params[:id].to_i, repo: params[:repo]&.to_sym)
49
+ @comments = filter_visible_comments(@issue.comments)
50
+ @support_user = support_user?
51
+ @current_user_id = pms_current_user.present? ? PMS::UserResolver.user_id(pms_current_user) : nil
52
+ end
53
+
54
+ # GET /issues/:id/edit
55
+ def edit
56
+ @issue = PMS::Issue.find(params[:id].to_i, repo: params[:repo]&.to_sym)
57
+ @support_user = support_user?
58
+ end
59
+
60
+ # PATCH/PUT /issues/:id
61
+ def update
62
+ @issue = PMS::Issue.find(params[:id].to_i, repo: params[:repo]&.to_sym)
63
+
64
+ @issue.update!(
65
+ title: issue_params[:title],
66
+ body: issue_params[:body],
67
+ labels: parse_labels(issue_params[:labels]),
68
+ )
69
+
70
+ flash[:success] = 'Issue was successfully updated.'
71
+ redirect_to(plan_my_stuff.issue_path(@issue.number))
72
+ rescue PMS::StaleObjectError
73
+ @support_user = support_user?
74
+ flash.now[:error] = 'Issue was modified by someone else. Please review the latest changes and try again.'
75
+ render(:edit, status: PMS.unprocessable_status)
76
+ end
77
+
78
+ # PATCH /issues/:id/close
79
+ def close
80
+ @issue = PMS::Issue.find(params[:id].to_i, repo: params[:repo]&.to_sym)
81
+ @issue.update!(state: :closed)
82
+
83
+ flash[:success] = 'Issue was successfully closed.'
84
+ redirect_to(plan_my_stuff.issue_path(@issue.number))
85
+ end
86
+
87
+ # PATCH /issues/:id/reopen
88
+ def reopen
89
+ @issue = PMS::Issue.find(params[:id].to_i, repo: params[:repo]&.to_sym)
90
+ @issue.update!(state: :open)
91
+
92
+ flash[:success] = 'Issue was successfully reopened.'
93
+ redirect_to(plan_my_stuff.issue_path(@issue.number))
94
+ end
95
+
96
+ # POST /issues/:id/add_viewers
97
+ def add_viewers
98
+ return redirect_to_unauthorized(plan_my_stuff.issue_path(params[:id])) unless support_user?
99
+
100
+ viewer_ids = parse_viewer_ids(params[:viewer_ids])
101
+ if viewer_ids.blank?
102
+ flash[:error] = 'No valid viewer IDs provided.'
103
+ redirect_to(plan_my_stuff.edit_issue_path(params[:id]))
104
+ return
105
+ end
106
+
107
+ PMS::Issue.add_viewers(number: params[:id].to_i, user_ids: viewer_ids, repo: params[:repo]&.to_sym)
108
+
109
+ flash[:success] = 'Viewers were successfully added.'
110
+ redirect_to(plan_my_stuff.edit_issue_path(params[:id]))
111
+ end
112
+
113
+ # DELETE /issues/:id/remove_viewer
114
+ def remove_viewer
115
+ return redirect_to_unauthorized(plan_my_stuff.issue_path(params[:id])) unless support_user?
116
+
117
+ viewer_id = params[:viewer_id].to_i
118
+ PMS::Issue.remove_viewers(number: params[:id].to_i, user_ids: [viewer_id], repo: params[:repo]&.to_sym)
119
+
120
+ flash[:success] = 'Viewer was successfully removed.'
121
+ redirect_to(plan_my_stuff.edit_issue_path(params[:id]))
122
+ end
123
+
124
+ private
125
+
126
+ # @return [ActionController::Parameters]
127
+ def issue_params
128
+ params.require(:issue).permit(:title, :body, :labels)
129
+ end
130
+
131
+ # Filters comments to only those visible to the current user.
132
+ # Falls back to showing all comments if no current_user is available.
133
+ #
134
+ # @param comments [Array<PlanMyStuff::Comment>]
135
+ #
136
+ # @return [Array<PlanMyStuff::Comment>]
137
+ #
138
+ def filter_visible_comments(comments)
139
+ user = pms_current_user
140
+ return comments unless user
141
+
142
+ comments.select { |comment| comment.visible_to?(user) }
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ class LabelsController < ApplicationController
5
+ # POST /issues/:issue_id/labels
6
+ def add_to_issue
7
+ labels = parse_labels(params[:label_name])
8
+ if labels.blank?
9
+ flash[:error] = 'Label name is required.'
10
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
11
+ return
12
+ end
13
+
14
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo]&.to_sym)
15
+ PMS::Label.add(issue: issue, labels: labels)
16
+
17
+ flash[:success] = 'Label was successfully added.'
18
+ redirect_to(plan_my_stuff.issue_path(issue.number))
19
+ end
20
+
21
+ # DELETE /issues/:issue_id/labels/:name
22
+ def remove_from_issue
23
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo]&.to_sym)
24
+ PMS::Label.remove(issue: issue, labels: [params[:name]])
25
+
26
+ flash[:success] = 'Label was successfully removed.'
27
+ redirect_to(plan_my_stuff.issue_path(issue.number))
28
+ end
29
+ end
30
+ end