plan_my_stuff 0.3.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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +569 -38
  4. data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
  5. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
  6. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
  7. data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
  9. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
  10. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
  11. data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
  12. data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
  13. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
  14. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
  15. data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
  16. data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
  17. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
  18. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
  19. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
  20. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
  21. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
  22. data/app/jobs/plan_my_stuff/application_job.rb +9 -0
  23. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
  24. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
  25. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
  26. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
  27. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
  28. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
  29. data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
  30. data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
  31. data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
  32. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
  33. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
  34. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
  35. data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
  36. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
  37. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
  38. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
  39. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  40. data/config/routes.rb +38 -15
  41. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
  42. data/lib/plan_my_stuff/application_record.rb +121 -0
  43. data/lib/plan_my_stuff/approval.rb +80 -0
  44. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  45. data/lib/plan_my_stuff/archive.rb +14 -0
  46. data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
  47. data/lib/plan_my_stuff/base_project.rb +661 -0
  48. data/lib/plan_my_stuff/base_project_item.rb +562 -0
  49. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  50. data/lib/plan_my_stuff/cache.rb +197 -0
  51. data/lib/plan_my_stuff/client.rb +7 -0
  52. data/lib/plan_my_stuff/comment.rb +171 -50
  53. data/lib/plan_my_stuff/configuration.rb +210 -10
  54. data/lib/plan_my_stuff/custom_fields.rb +31 -17
  55. data/lib/plan_my_stuff/engine.rb +0 -4
  56. data/lib/plan_my_stuff/errors.rb +49 -0
  57. data/lib/plan_my_stuff/graphql/queries.rb +392 -0
  58. data/lib/plan_my_stuff/issue.rb +1476 -175
  59. data/lib/plan_my_stuff/issue_metadata.rb +122 -0
  60. data/lib/plan_my_stuff/label.rb +82 -11
  61. data/lib/plan_my_stuff/link.rb +144 -0
  62. data/lib/plan_my_stuff/notifications.rb +142 -0
  63. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  64. data/lib/plan_my_stuff/pipeline/status.rb +44 -0
  65. data/lib/plan_my_stuff/pipeline.rb +293 -0
  66. data/lib/plan_my_stuff/project.rb +30 -693
  67. data/lib/plan_my_stuff/project_item.rb +3 -417
  68. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  69. data/lib/plan_my_stuff/project_metadata.rb +9 -3
  70. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  71. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  72. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  73. data/lib/plan_my_stuff/reminders.rb +16 -0
  74. data/lib/plan_my_stuff/test_helpers.rb +260 -15
  75. data/lib/plan_my_stuff/testing_project.rb +291 -0
  76. data/lib/plan_my_stuff/testing_project_item.rb +184 -0
  77. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  78. data/lib/plan_my_stuff/user_resolver.rb +8 -3
  79. data/lib/plan_my_stuff/version.rb +1 -1
  80. data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
  81. data/lib/plan_my_stuff.rb +15 -0
  82. data/lib/tasks/plan_my_stuff.rake +163 -0
  83. metadata +50 -2
@@ -11,10 +11,14 @@ module PlanMyStuff
11
11
  body: comment_params[:body],
12
12
  user: pms_current_user,
13
13
  visibility: comment_params[:visibility]&.to_sym || :public,
14
+ waiting_on_reply: comment_params[:waiting_on_reply] == '1',
14
15
  )
15
16
 
16
17
  flash[:success] = 'Comment was successfully created.'
17
18
  redirect_to(plan_my_stuff.issue_path(@issue.number, repo: @issue.repo.full_name))
19
+ rescue PMS::LockedIssueError
20
+ flash[:error] = 'This issue is locked; no new comments can be posted.'
21
+ redirect_to(plan_my_stuff.issue_path(@issue.number, repo: @issue.repo.full_name))
18
22
  end
19
23
 
20
24
  # GET /issues/:issue_id/comments/:id/edit
@@ -58,7 +62,7 @@ module PlanMyStuff
58
62
 
59
63
  # @return [ActionController::Parameters]
60
64
  def comment_params
61
- params.require(:comment).permit(:body, :visibility)
65
+ params.require(:comment).permit(:body, :visibility, :waiting_on_reply)
62
66
  end
63
67
 
64
68
  # Loads the issue and comment from params.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # CRUD for manager approvals on an issue. Backs the approvals panel
6
+ # on the issue show view.
7
+ #
8
+ # POST /issues/:issue_id/approvals -> create (adds approver(s))
9
+ # PATCH /issues/:issue_id/approvals/:id -> update (approves or revokes)
10
+ # DELETE /issues/:issue_id/approvals/:id -> destroy (removes an approver)
11
+ #
12
+ class ApprovalsController < ApplicationController
13
+ # POST /issues/:issue_id/approvals
14
+ def create
15
+ unless support_user?
16
+ redirect_to_unauthorized(show_path)
17
+ return
18
+ end
19
+
20
+ user_ids = parse_viewer_ids(approval_params[:user_ids])
21
+ if user_ids.blank?
22
+ flash[:error] = 'No valid user IDs provided.'
23
+ redirect_to(show_path)
24
+ return
25
+ end
26
+
27
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
28
+ issue.request_approvals!(user_ids: user_ids, user: pms_current_user)
29
+
30
+ flash[:success] = 'Approvers were successfully added.'
31
+ redirect_to(show_path)
32
+ rescue PMS::AuthorizationError, PMS::ValidationError => e
33
+ flash[:error] = e.message
34
+ redirect_to(show_path)
35
+ end
36
+
37
+ # PATCH /issues/:issue_id/approvals/:id
38
+ def update
39
+ status = approval_params[:status].to_s
40
+ target_id = params[:id].to_i
41
+
42
+ if %w[approved pending].exclude?(status)
43
+ head(:bad_request)
44
+ return
45
+ end
46
+
47
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
48
+ caller_id = pms_current_user.present? ? PMS::UserResolver.user_id(pms_current_user) : nil
49
+
50
+ if status == 'approved'
51
+ unless caller_id == target_id
52
+ redirect_to_unauthorized(show_path)
53
+ return
54
+ end
55
+ issue.approve!(user: pms_current_user)
56
+ flash[:success] = 'Approval recorded.'
57
+ else
58
+ if caller_id != target_id && !support_user?
59
+ redirect_to_unauthorized(show_path)
60
+ return
61
+ end
62
+ issue.revoke_approval!(user: pms_current_user, target_user_id: target_id)
63
+ flash[:success] = 'Approval revoked.'
64
+ end
65
+
66
+ redirect_to(show_path)
67
+ rescue PMS::AuthorizationError, PMS::ValidationError => e
68
+ flash[:error] = e.message
69
+ redirect_to(show_path)
70
+ end
71
+
72
+ # DELETE /issues/:issue_id/approvals/:id
73
+ def destroy
74
+ unless support_user?
75
+ redirect_to_unauthorized(show_path)
76
+ return
77
+ end
78
+
79
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
80
+ issue.remove_approvers!(user_ids: [params[:id].to_i], user: pms_current_user)
81
+
82
+ flash[:success] = 'Approver was successfully removed.'
83
+ redirect_to(show_path)
84
+ rescue PMS::AuthorizationError, PMS::ValidationError => e
85
+ flash[:error] = e.message
86
+ redirect_to(show_path)
87
+ end
88
+
89
+ private
90
+
91
+ # @return [ActionController::Parameters]
92
+ def approval_params
93
+ params.fetch(:approval, {}).permit(:status, :user_ids)
94
+ end
95
+
96
+ # @return [String]
97
+ def show_path
98
+ plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo])
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # Handles closing and reopening issues via CRUD-style routes.
6
+ # Backs the Close/Reopen buttons on the issue show view (T-044).
7
+ #
8
+ # POST /issues/:issue_id/closure -> create (closes)
9
+ # DELETE /issues/:issue_id/closure -> destroy (reopens)
10
+ #
11
+ class ClosuresController < ApplicationController
12
+ # POST /issues/:issue_id/closure
13
+ def create
14
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
15
+ issue.update!(state: :closed)
16
+
17
+ flash[:success] = 'Issue was successfully closed.'
18
+ redirect_to(plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name))
19
+ rescue PMS::Error, ArgumentError => e
20
+ flash[:error] = e.message
21
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
22
+ end
23
+
24
+ # DELETE /issues/:issue_id/closure
25
+ def destroy
26
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
27
+ issue.update!(state: :open)
28
+
29
+ flash[:success] = 'Issue was successfully reopened.'
30
+ redirect_to(plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name))
31
+ rescue PMS::Error, ArgumentError => e
32
+ flash[:error] = e.message
33
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # CRUD for ticket relationships: +:related+ (metadata-backed) and
6
+ # +:blocked_by+ / +:parent+ / +:sub_ticket+ / +:duplicate_of+
7
+ # (native GitHub APIs). Backs the links panel on the issue show view.
8
+ #
9
+ # POST /issues/:issue_id/links -> create (adds a link)
10
+ # DELETE /issues/:issue_id/links/:id -> destroy (removes a link)
11
+ #
12
+ class LinksController < ApplicationController
13
+ NATIVE_DISPATCH = {
14
+ 'related' => { add: :add_related!, remove: :remove_related! },
15
+ 'blocked_by' => { add: :add_blocker!, remove: :remove_blocker! },
16
+ 'sub_ticket' => { add: :add_sub_issue!, remove: :remove_sub_issue! },
17
+ 'parent' => { add: :set_parent!, remove: :remove_parent! },
18
+ 'duplicate_of' => { add: :mark_duplicate! },
19
+ }.freeze
20
+
21
+ # POST /issues/:issue_id/links
22
+ def create
23
+ type = link_params[:type].to_s
24
+ unless dispatch_allowed?(type, :add)
25
+ redirect_to_unauthorized(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
26
+ return
27
+ end
28
+
29
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
30
+ link = add_link(issue, type)
31
+
32
+ flash[:success] = "Linked #{link}"
33
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
34
+ rescue PMS::ValidationError, ActiveModel::ValidationError, ArgumentError => e
35
+ flash[:error] = e.message
36
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
37
+ end
38
+
39
+ # DELETE /issues/:issue_id/links/:id
40
+ def destroy
41
+ type, repo, number = parse_composite_id(params[:id])
42
+ unless dispatch_allowed?(type, :remove)
43
+ redirect_to_unauthorized(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
44
+ return
45
+ end
46
+
47
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
48
+ remove_link(issue, type, repo: repo, number: number)
49
+
50
+ flash[:success] = "Unlinked #{repo}##{number}"
51
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
52
+ rescue PMS::ValidationError, ActiveModel::ValidationError, ArgumentError => e
53
+ flash[:error] = e.message
54
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
55
+ end
56
+
57
+ private
58
+
59
+ # @return [ActionController::Parameters]
60
+ def link_params
61
+ params.require(:link).permit(:type, :issue_number, :repo)
62
+ end
63
+
64
+ # Only +:related+ may be mutated by non-support users. +destroy+
65
+ # is not defined for +:duplicate_of+ (reopen via GitHub).
66
+ #
67
+ # @return [Boolean]
68
+ #
69
+ def dispatch_allowed?(type, action)
70
+ entry = NATIVE_DISPATCH[type]
71
+ return false if entry.nil? || entry[action].nil?
72
+ # FIXME: should non-support users be able to alter related links?
73
+ return true if type == 'related'
74
+
75
+ support_user?
76
+ end
77
+
78
+ # @return [PMS::Link]
79
+ def add_link(issue, type)
80
+ method = NATIVE_DISPATCH.dig(type, :add)
81
+ target = {
82
+ type: type,
83
+ issue_number: link_params[:issue_number].to_i,
84
+ repo: link_params[:repo].presence || issue.repo.to_s,
85
+ }
86
+ case type
87
+ when 'related', 'duplicate_of'
88
+ issue.public_send(method, target, user: pms_current_user)
89
+ else
90
+ issue.public_send(method, target)
91
+ end
92
+ end
93
+
94
+ # @return [void]
95
+ def remove_link(issue, type, repo:, number:)
96
+ method = NATIVE_DISPATCH.dig(type, :remove)
97
+ target = { type: type, issue_number: number, repo: repo }
98
+ case type
99
+ when 'parent'
100
+ issue.public_send(method)
101
+ when 'related'
102
+ issue.public_send(method, target, user: pms_current_user)
103
+ else
104
+ issue.public_send(method, target)
105
+ end
106
+ end
107
+
108
+ # Parses +"{type}:{owner/repo}:{number}"+ out of +params[:id]+.
109
+ # The route helper URL-encodes outgoing segments, but Rack's
110
+ # default path decoder intentionally leaves +%2F+ escaped in
111
+ # path params (anti-path-traversal), so the repo portion can
112
+ # still contain +%2F+. Decode before splitting.
113
+ #
114
+ # @param id [String]
115
+ #
116
+ # @return [Array(String, String, Integer)] type, repo, number
117
+ #
118
+ def parse_composite_id(id)
119
+ decoded = CGI.unescape(id.to_s)
120
+ type, repo, number = decoded.split(':', 3)
121
+ raise(ArgumentError, "Invalid link id: #{id.inspect}") if number.blank?
122
+
123
+ [type, repo, number.to_i]
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # Moves an issue's pipeline ProjectItem to "Started" via
6
+ # +PlanMyStuff::Pipeline.take!+. Backs the "Take" button on the
7
+ # mounted issue show view (T-RC-017). The primary UI path for a dev
8
+ # picking up work on an issue they've been assigned to.
9
+ #
10
+ class TakesController < ApplicationController
11
+ before_action :require_support_user!
12
+
13
+ # POST /issues/:issue_id/take
14
+ def create
15
+ issue_number = params[:issue_id].to_i
16
+ repo = params[:repo]
17
+
18
+ project_item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
19
+ project_item ||= add_to_pipeline(issue_number, repo)
20
+
21
+ PlanMyStuff::Pipeline.take!(project_item)
22
+ assign_current_user(project_item)
23
+ flash[:success] ||= "Issue ##{issue_number} taken."
24
+
25
+ redirect_to(plan_my_stuff.issue_path(issue_number, repo: repo))
26
+ rescue ArgumentError, PlanMyStuff::Error => e
27
+ flash[:error] = e.message
28
+ redirect_to(plan_my_stuff.issue_path(issue_number, repo: repo))
29
+ end
30
+
31
+ private
32
+
33
+ # Redirects non-support users back to the issue page. Mirrors
34
+ # +Issues::ViewersController+'s authorization check.
35
+ #
36
+ # @return [void]
37
+ #
38
+ def require_support_user!
39
+ return if support_user?
40
+
41
+ redirect_to_unauthorized(
42
+ plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]),
43
+ )
44
+ end
45
+
46
+ # Adds an issue to the pipeline project so +Pipeline.take!+ has
47
+ # something to operate on when the user clicks "Take" on an
48
+ # issue that hasn't been submitted to the pipeline yet.
49
+ #
50
+ # @param issue_number [Integer]
51
+ # @param repo [String, nil]
52
+ #
53
+ # @return [PlanMyStuff::ProjectItem]
54
+ #
55
+ def add_to_pipeline(issue_number, repo)
56
+ issue = PlanMyStuff::Issue.find(issue_number, repo: repo)
57
+ pipeline_number = PlanMyStuff::Pipeline.resolve_pipeline_project_number
58
+ PlanMyStuff::ProjectItem.create!(issue, project_number: pipeline_number)
59
+ end
60
+
61
+ # Looks up the current user's GitHub login in
62
+ # +config.github_login_for+ (a +{user_id => login}+ hash) and
63
+ # assigns them on the project item. Assignment failures are
64
+ # logged and surfaced as a flash warning but do not revert the
65
+ # status move.
66
+ #
67
+ # @param project_item [PlanMyStuff::ProjectItem]
68
+ #
69
+ # @return [void]
70
+ #
71
+ def assign_current_user(project_item)
72
+ user = pms_current_user
73
+ user_id = user.present? ? PMS::UserResolver.user_id(user) : nil
74
+ login = PlanMyStuff.configuration.github_login_for[user_id]
75
+
76
+ if login.blank?
77
+ Rails.logger.info("[PMS] No github_login_for mapping for user #{user_id}; skipping Take assignment")
78
+ return
79
+ end
80
+
81
+ project_item.assign!(login)
82
+ rescue PlanMyStuff::Error => e
83
+ Rails.logger.warn("[PMS] Take assignment failed: #{e.message}")
84
+ flash[:warning] = "Issue taken but assignment failed: #{e.message}"
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # Handles adding and removing viewers from the visibility allowlist via CRUD-style routes.
6
+ # Backs the viewer management UI on the issue edit view (T-045).
7
+ #
8
+ # POST /issues/:issue_id/viewers -> create (adds viewer(s))
9
+ # DELETE /issues/:issue_id/viewers/:id -> destroy (removes a viewer)
10
+ #
11
+ class ViewersController < ApplicationController
12
+ # POST /issues/:issue_id/viewers
13
+ def create
14
+ unless support_user?
15
+ redirect_to_unauthorized(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
16
+ return
17
+ end
18
+
19
+ viewer_ids = parse_viewer_ids(params[:viewer_ids])
20
+ if viewer_ids.blank?
21
+ flash[:error] = 'No valid viewer IDs provided.'
22
+ redirect_to(plan_my_stuff.edit_issue_path(params[:issue_id], repo: params[:repo]))
23
+ return
24
+ end
25
+
26
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
27
+ issue.add_viewers(user_ids: viewer_ids, user: pms_current_user)
28
+
29
+ flash[:success] = 'Viewers were successfully added.'
30
+ redirect_to(plan_my_stuff.edit_issue_path(params[:issue_id], repo: params[:repo]))
31
+ end
32
+
33
+ # DELETE /issues/:issue_id/viewers/:id
34
+ def destroy
35
+ unless support_user?
36
+ redirect_to_unauthorized(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
37
+ return
38
+ end
39
+
40
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
41
+ issue.remove_viewers(user_ids: [params[:id].to_i], user: pms_current_user)
42
+
43
+ flash[:success] = 'Viewer was successfully removed.'
44
+ redirect_to(plan_my_stuff.edit_issue_path(params[:issue_id], repo: params[:repo]))
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # Toggles the waiting-on-user state on an issue via CRUD-style routes.
6
+ # Backs the "Mark waiting" / "Mark replied" button on the issue show view.
7
+ #
8
+ # POST /issues/:issue_id/waiting -> create (enters waiting-on-user)
9
+ # DELETE /issues/:issue_id/waiting -> destroy (clears waiting-on-user)
10
+ #
11
+ class WaitingsController < ApplicationController
12
+ # POST /issues/:issue_id/waiting
13
+ def create
14
+ unless support_user?
15
+ redirect_to_unauthorized(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
16
+ return
17
+ end
18
+
19
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
20
+ issue.enter_waiting_on_user!(user: pms_current_user)
21
+
22
+ flash[:success] = 'Issue marked as waiting on user reply.'
23
+ redirect_to(plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name))
24
+ rescue PMS::Error, ArgumentError => e
25
+ flash[:error] = e.message
26
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
27
+ end
28
+
29
+ # DELETE /issues/:issue_id/waiting
30
+ def destroy
31
+ unless support_user?
32
+ redirect_to_unauthorized(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
33
+ return
34
+ end
35
+
36
+ issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
37
+ issue.clear_waiting_on_user!
38
+
39
+ flash[:success] = 'Waiting-on-user state cleared.'
40
+ redirect_to(plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name))
41
+ rescue PMS::Error, ArgumentError => e
42
+ flash[:error] = e.message
43
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id], repo: params[:repo]))
44
+ end
45
+ end
46
+ end
47
+ end
@@ -49,6 +49,8 @@ module PlanMyStuff
49
49
  @comments = filter_visible_comments(@issue.comments)
50
50
  @support_user = support_user?
51
51
  @current_user_id = pms_current_user.present? ? PMS::UserResolver.user_id(pms_current_user) : nil
52
+ @pipeline_enabled = PMS.configuration.pipeline_enabled
53
+ @pipeline_item = load_pipeline_item(@issue.number) if @pipeline_enabled
52
54
  end
53
55
 
54
56
  # GET /issues/:id/edit
@@ -73,58 +75,10 @@ module PlanMyStuff
73
75
  @support_user = support_user?
74
76
  flash.now[:error] = 'Issue was modified by someone else. Please review the latest changes and try again.'
75
77
  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])
81
- @issue.update!(state: :closed)
82
-
83
- flash[:success] = 'Issue was successfully closed.'
84
- redirect_to(plan_my_stuff.issue_path(@issue.number, repo: @issue.repo.full_name))
85
- end
86
-
87
- # PATCH /issues/:id/reopen
88
- def reopen
89
- @issue = PMS::Issue.find(params[:id].to_i, repo: params[:repo])
90
- @issue.update!(state: :open)
91
-
92
- flash[:success] = 'Issue was successfully reopened.'
93
- redirect_to(plan_my_stuff.issue_path(@issue.number, repo: @issue.repo.full_name))
94
- end
95
-
96
- # POST /issues/:id/add_viewers
97
- def add_viewers
98
- unless support_user?
99
- redirect_to_unauthorized(plan_my_stuff.issue_path(params[:id], repo: params[:repo]))
100
- return
101
- end
102
-
103
- viewer_ids = parse_viewer_ids(params[:viewer_ids])
104
- if viewer_ids.blank?
105
- flash[:error] = 'No valid viewer IDs provided.'
106
- redirect_to(plan_my_stuff.edit_issue_path(params[:id], repo: params[:repo]))
107
- return
108
- end
109
-
110
- PMS::Issue.add_viewers(number: params[:id].to_i, user_ids: viewer_ids, repo: params[:repo])
111
-
112
- flash[:success] = 'Viewers were successfully added.'
113
- redirect_to(plan_my_stuff.edit_issue_path(params[:id], repo: params[:repo]))
114
- end
115
-
116
- # DELETE /issues/:id/remove_viewer
117
- def remove_viewer
118
- unless support_user?
119
- redirect_to_unauthorized(plan_my_stuff.issue_path(params[:id], repo: params[:repo]))
120
- return
121
- end
122
-
123
- viewer_id = params[:viewer_id].to_i
124
- PMS::Issue.remove_viewers(number: params[:id].to_i, user_ids: [viewer_id], repo: params[:repo])
125
-
126
- flash[:success] = 'Viewer was successfully removed.'
127
- redirect_to(plan_my_stuff.edit_issue_path(params[:id], repo: params[:repo]))
78
+ rescue PMS::ValidationError => e
79
+ @support_user = support_user?
80
+ flash.now[:error] = e.message
81
+ render(:edit, status: PMS.unprocessable_status)
128
82
  end
129
83
 
130
84
  private
@@ -135,7 +89,6 @@ module PlanMyStuff
135
89
  end
136
90
 
137
91
  # Filters comments to only those visible to the current user.
138
- # Falls back to showing all comments if no current_user is available.
139
92
  #
140
93
  # @param comments [Array<PlanMyStuff::Comment>]
141
94
  #
@@ -143,9 +96,23 @@ module PlanMyStuff
143
96
  #
144
97
  def filter_visible_comments(comments)
145
98
  user = pms_current_user
146
- return comments unless user
147
-
148
99
  comments.select { |comment| comment.visible_to?(user) }
149
100
  end
101
+
102
+ # Looks up the pipeline ProjectItem for this issue, if any.
103
+ # Returns nil when the item cannot be loaded for any reason --
104
+ # issue show should never break because the pipeline lookup
105
+ # failed.
106
+ #
107
+ # @param issue_number [Integer]
108
+ #
109
+ # @return [PlanMyStuff::ProjectItem, nil]
110
+ #
111
+ def load_pipeline_item(issue_number)
112
+ PMS::Pipeline::IssueLinker.find_project_item(issue_number)
113
+ rescue ArgumentError, PMS::Error => e
114
+ Rails.logger.warn("[PlanMyStuff] Failed to load pipeline item for issue ##{issue_number}: #{e.message}")
115
+ nil
116
+ end
150
117
  end
151
118
  end
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  class LabelsController < ApplicationController
5
5
  # POST /issues/:issue_id/labels
6
- def add_to_issue
6
+ def create
7
7
  labels = parse_labels(params[:label_name])
8
8
  if labels.blank?
9
9
  flash[:error] = 'Label name is required.'
@@ -18,10 +18,10 @@ module PlanMyStuff
18
18
  redirect_to(plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name))
19
19
  end
20
20
 
21
- # DELETE /issues/:issue_id/labels/:name
22
- def remove_from_issue
21
+ # DELETE /issues/:issue_id/labels/:id
22
+ def destroy
23
23
  issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo])
24
- PMS::Label.remove(issue: issue, labels: [params[:name]])
24
+ PMS::Label.remove(issue: issue, labels: [params[:id]])
25
25
 
26
26
  flash[:success] = 'Label was successfully removed.'
27
27
  redirect_to(plan_my_stuff.issue_path(issue.number, repo: issue.repo.full_name))
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module ProjectItems
5
+ # Handles assigning and unassigning project item assignees via CRUD-style routes.
6
+ # Backs the assign/unassign forms on the project board view (T-047).
7
+ #
8
+ # PATCH /projects/:project_id/items/:item_id/assignment -> update (assign)
9
+ # DELETE /projects/:project_id/items/:item_id/assignment -> destroy (unassign)
10
+ #
11
+ class AssignmentsController < ApplicationController
12
+ # PATCH /projects/:project_id/items/:item_id/assignment
13
+ def update
14
+ item = find_project_item
15
+ assignees = parse_assignees(params[:assignee])
16
+
17
+ item.assign!(assignees)
18
+
19
+ flash[:success] = "Item assigned to #{assignees.join(', ')}."
20
+ redirect_to(plan_my_stuff.project_path(params[:project_id]))
21
+ rescue ArgumentError, PMS::Error => e
22
+ flash[:error] = e.message
23
+ redirect_to(plan_my_stuff.project_path(params[:project_id]))
24
+ end
25
+
26
+ # DELETE /projects/:project_id/items/:item_id/assignment
27
+ def destroy
28
+ if params[:username].blank?
29
+ flash[:error] = 'Username is required to unassign.'
30
+ redirect_to(plan_my_stuff.project_path(params[:project_id]))
31
+ return
32
+ end
33
+
34
+ item = find_project_item
35
+ current_assignees = item.field_values['Assignees'] || []
36
+ remaining = current_assignees - [params[:username]]
37
+
38
+ item.assign!(remaining)
39
+
40
+ flash[:success] = "#{params[:username]} unassigned."
41
+ redirect_to(plan_my_stuff.project_path(params[:project_id]))
42
+ rescue ArgumentError, PMS::Error => e
43
+ flash[:error] = e.message
44
+ redirect_to(plan_my_stuff.project_path(params[:project_id]))
45
+ end
46
+
47
+ private
48
+
49
+ # Finds the project item by item_id within the given project.
50
+ #
51
+ # @return [PlanMyStuff::ProjectItem]
52
+ #
53
+ def find_project_item
54
+ project = PMS::Project.find(params[:project_id].to_i)
55
+ item = project.items.find { |i| i.id == params[:item_id] }
56
+
57
+ raise(PMS::APIError, "Item not found: #{params[:item_id]}") unless item
58
+
59
+ item
60
+ end
61
+
62
+ # Splits a comma-separated assignees string into an array.
63
+ #
64
+ # @param assignees_string [String, nil]
65
+ #
66
+ # @return [Array<String>]
67
+ #
68
+ def parse_assignees(assignees_string)
69
+ return [] if assignees_string.blank?
70
+
71
+ assignees_string.split(',').filter_map { |a| a.strip.presence }
72
+ end
73
+ end
74
+ end
75
+ end