plan_my_stuff 0.1.0 → 1.0.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. metadata +99 -4
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- class ApplicationController < ::ApplicationController
4
+ class ApplicationController < PlanMyStuff.configuration.parent_controller.constantize
5
5
  protect_from_forgery with: :exception
6
6
  helper Rails.application.routes.url_helpers
7
7
 
8
8
  before_action :authenticate_pms_user!
9
+ before_action :set_support_user
9
10
 
10
11
  private
11
12
 
@@ -23,8 +24,12 @@ module PlanMyStuff
23
24
  instance_exec(&pms_auth) if pms_auth
24
25
  end
25
26
 
26
- # Returns the current user for PMS visibility checks.
27
- # Delegates to the consuming app's current_user method.
27
+ # @return [void]
28
+ def set_support_user
29
+ @support_user = support_user?
30
+ end
31
+
32
+ # Returns the current user for PMS visibility checks. Delegates to the consuming app's current_user method.
28
33
  #
29
34
  # @return [Object, nil]
30
35
  #
@@ -34,7 +39,7 @@ module PlanMyStuff
34
39
 
35
40
  # @return [Boolean]
36
41
  def support_user?
37
- pms_current_user.present? && PMS::UserResolver.support?(pms_current_user)
42
+ pms_current_user.present? && PlanMyStuff::UserResolver.support?(pms_current_user)
38
43
  end
39
44
 
40
45
  # Redirects non-support users back with an error.
@@ -49,6 +54,20 @@ module PlanMyStuff
49
54
  redirect_to(path)
50
55
  end
51
56
 
57
+ # Logs +error+ and invokes the consuming app's +PlanMyStuff.configuration.controller_rescue+ hook so monitoring
58
+ # can fire even when the rescue swallows the error and redirects to a flash. Wired into every user-facing
59
+ # controller +rescue+.
60
+ #
61
+ # @param error [StandardError]
62
+ #
63
+ # @return [void]
64
+ #
65
+ def pms_handle_rescue(error)
66
+ Rails.logger.error("[PlanMyStuff] #{error.class}: #{error.message}")
67
+ Rails.logger.error(error.backtrace.join("\n")) if error.backtrace
68
+ PlanMyStuff.configuration.controller_rescue&.call(error)
69
+ end
70
+
52
71
  # Splits a comma-separated labels string into an array.
53
72
  #
54
73
  # @param labels_string [String, nil]
@@ -70,7 +89,10 @@ module PlanMyStuff
70
89
  def parse_viewer_ids(ids_string)
71
90
  return [] if ids_string.blank?
72
91
 
73
- ids_string.split(',').filter_map { |id| id.strip.presence&.to_i }
92
+ ids_string.split(',').filter_map do |id|
93
+ token = id.strip
94
+ token.to_i if token.match?(/\A\d+\z/)
95
+ end
74
96
  end
75
97
  end
76
98
  end
@@ -1,56 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- class CommentsController < ApplicationController
4
+ class CommentsController < PlanMyStuff::ApplicationController
5
5
  # POST /issues/:issue_id/comments
6
6
  def create
7
- @issue = PMS::Issue.find(params[:issue_id].to_i, repo: params[:repo]&.to_sym)
7
+ @issue = PlanMyStuff::Issue.find(params[:issue_id])
8
8
 
9
- PMS::Comment.create!(
9
+ comment = PlanMyStuff::Comment.create!(
10
10
  issue: @issue,
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
 
17
+ yield(comment) if block_given?
18
+ return if performed?
19
+
16
20
  flash[:success] = 'Comment was successfully created.'
17
- redirect_to(plan_my_stuff.issue_path(@issue.number))
21
+ redirect_to(plan_my_stuff.issue_path(@issue))
22
+ rescue PlanMyStuff::LockedIssueError => e
23
+ pms_handle_rescue(e)
24
+ flash[:error] = 'This issue is locked; no new comments can be posted.'
25
+ redirect_to(plan_my_stuff.issue_path(@issue))
18
26
  end
19
27
 
20
28
  # GET /issues/:issue_id/comments/:id/edit
21
29
  def edit
22
30
  load_comment
23
31
  return unless @comment
32
+ return redirect_to_issue if issue_body_comment?
33
+
34
+ unless can_edit?(@comment)
35
+ redirect_to_unauthorized(plan_my_stuff.issue_path(@issue))
36
+
37
+ return
38
+ end
24
39
 
25
- @support_user = support_user?
26
- return redirect_to_unauthorized(plan_my_stuff.issue_path(@issue.number)) unless can_edit?(@comment)
40
+ yield(@comment) if block_given?
27
41
  end
28
42
 
29
43
  # PATCH/PUT /issues/:issue_id/comments/:id
30
44
  def update
31
45
  load_comment
32
46
  return unless @comment
47
+ return redirect_to_issue if issue_body_comment?
33
48
 
34
- @support_user = support_user?
35
- return redirect_to_unauthorized(plan_my_stuff.issue_path(@issue.number)) unless can_edit?(@comment)
49
+ unless can_edit?(@comment)
50
+ redirect_to_unauthorized(plan_my_stuff.issue_path(@issue))
51
+
52
+ return
53
+ end
36
54
 
37
55
  update_attrs = { body: comment_params[:body] }
38
56
  update_attrs[:visibility] = comment_params[:visibility].to_sym if @support_user && comment_params[:visibility]
39
57
 
40
- @comment.update!(**update_attrs)
58
+ @comment.update!(**update_attrs, user: pms_current_user)
59
+
60
+ yield(@comment) if block_given?
61
+ return if performed?
41
62
 
42
63
  flash[:success] = 'Comment was successfully updated.'
43
- redirect_to(plan_my_stuff.issue_path(@issue.number))
44
- rescue PMS::StaleObjectError
64
+ redirect_to(plan_my_stuff.issue_path(@issue))
65
+ rescue PlanMyStuff::StaleObjectError => e
66
+ pms_handle_rescue(e)
45
67
  flash.now[:error] = 'Comment was modified by someone else. Please review the latest changes and try again.'
46
- render(:edit, status: PMS.unprocessable_status)
68
+ render(:edit, status: PlanMyStuff.unprocessable_status)
47
69
  end
48
70
 
49
71
  private
50
72
 
51
73
  # @return [ActionController::Parameters]
52
74
  def comment_params
53
- params.require(:comment).permit(:body, :visibility)
75
+ params.require(:comment).permit(:body, :visibility, :waiting_on_reply)
54
76
  end
55
77
 
56
78
  # Loads the issue and comment from params.
@@ -58,13 +80,12 @@ module PlanMyStuff
58
80
  # @return [void]
59
81
  #
60
82
  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)
83
+ @issue = PlanMyStuff::Issue.find(params[:issue_id])
84
+ @comment = PlanMyStuff::Comment.find(params[:id].to_i, issue: @issue)
63
85
  end
64
86
 
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.
87
+ # Returns true if the current user can edit the given comment. Support users can edit any comment. Regular users
88
+ # can only edit their own comments.
68
89
  #
69
90
  # @param comment [PlanMyStuff::Comment]
70
91
  #
@@ -76,7 +97,17 @@ module PlanMyStuff
76
97
  user = pms_current_user
77
98
  return false if user.blank?
78
99
 
79
- comment.metadata.created_by == PMS::UserResolver.user_id(user)
100
+ comment.metadata.created_by == PlanMyStuff::UserResolver.user_id(user)
101
+ end
102
+
103
+ # @return [Boolean]
104
+ def issue_body_comment?
105
+ @comment.metadata.issue_body?
106
+ end
107
+
108
+ # @return [void]
109
+ def redirect_to_issue
110
+ redirect_to(plan_my_stuff.issue_path(@issue))
80
111
  end
81
112
  end
82
113
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # CRUD for manager approvals on an issue. Backs the approvals panel on the issue show view.
6
+ #
7
+ # POST /issues/:issue_id/approvals -> create (adds approver(s))
8
+ # PATCH /issues/:issue_id/approvals/:id -> update (approves or revokes)
9
+ # DELETE /issues/:issue_id/approvals/:id -> destroy (removes an approver)
10
+ #
11
+ class ApprovalsController < PlanMyStuff::ApplicationController
12
+ # POST /issues/:issue_id/approvals
13
+ def create
14
+ unless support_user?
15
+ redirect_to_unauthorized(show_path)
16
+ return
17
+ end
18
+
19
+ user_ids = parse_viewer_ids(approval_params[:user_ids])
20
+ if user_ids.blank?
21
+ flash[:error] = 'No valid user IDs provided.'
22
+ redirect_to(show_path)
23
+ return
24
+ end
25
+
26
+ find_issue
27
+
28
+ @issue.request_approvals!(user_ids: user_ids, user: pms_current_user)
29
+
30
+ yield(@issue) if block_given?
31
+ return if performed?
32
+
33
+ flash[:success] = 'Approvers were successfully added.'
34
+ redirect_to(show_path)
35
+ rescue PlanMyStuff::AuthorizationError, PlanMyStuff::ValidationError => e
36
+ pms_handle_rescue(e)
37
+ flash[:error] = e.message
38
+ redirect_to(show_path)
39
+ end
40
+
41
+ # PATCH /issues/:issue_id/approvals/:id
42
+ def update
43
+ status = approval_params[:status].to_s
44
+ target_id = params[:id].to_i
45
+
46
+ if PlanMyStuff::Approval::STATUSES.exclude?(status)
47
+ head(:bad_request)
48
+ return
49
+ end
50
+
51
+ find_issue
52
+ caller_id = pms_current_user.present? ? PlanMyStuff::UserResolver.user_id(pms_current_user) : nil
53
+
54
+ case status
55
+ when 'approved'
56
+ unless caller_id == target_id
57
+ redirect_to_unauthorized(show_path)
58
+ return
59
+ end
60
+ @issue.approve!(user: pms_current_user)
61
+ flash[:success] = 'Approval recorded.'
62
+ when 'rejected'
63
+ unless caller_id == target_id
64
+ redirect_to_unauthorized(show_path)
65
+ return
66
+ end
67
+ @issue.reject!(user: pms_current_user)
68
+ flash[:success] = 'Rejection recorded.'
69
+ else
70
+ if caller_id != target_id && !support_user?
71
+ redirect_to_unauthorized(show_path)
72
+ return
73
+ end
74
+ @issue.revoke_approval!(user: pms_current_user, target_user_id: target_id)
75
+ flash[:success] = 'Response revoked.'
76
+ end
77
+
78
+ yield(@issue) if block_given?
79
+ return if performed?
80
+
81
+ redirect_to(show_path)
82
+ rescue PlanMyStuff::AuthorizationError, PlanMyStuff::ValidationError => e
83
+ pms_handle_rescue(e)
84
+ flash[:error] = e.message
85
+ redirect_to(show_path)
86
+ end
87
+
88
+ # DELETE /issues/:issue_id/approvals/:id
89
+ def destroy
90
+ unless support_user?
91
+ redirect_to_unauthorized(show_path)
92
+ return
93
+ end
94
+
95
+ find_issue
96
+ @issue.remove_approvers!(user_ids: [params[:id].to_i], user: pms_current_user)
97
+
98
+ yield(@issue) if block_given?
99
+ return if performed?
100
+
101
+ flash[:success] = 'Approver was successfully removed.'
102
+ redirect_to(show_path)
103
+ rescue PlanMyStuff::AuthorizationError, PlanMyStuff::ValidationError => e
104
+ pms_handle_rescue(e)
105
+ flash[:error] = e.message
106
+ redirect_to(show_path)
107
+ end
108
+
109
+ private
110
+
111
+ # @return [ActionController::Parameters]
112
+ def approval_params
113
+ params.fetch(:approval, {}).permit(:status, :user_ids)
114
+ end
115
+
116
+ # :nodoc:
117
+ def find_issue
118
+ @issue = PlanMyStuff::Issue.find(params[:issue_id])
119
+ end
120
+
121
+ # @return [String]
122
+ def show_path
123
+ plan_my_stuff.issue_path(@issue || params[:issue_id])
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,53 @@
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 < PlanMyStuff::ApplicationController
12
+ # POST /issues/:issue_id/closure
13
+ def create
14
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
15
+ to_update = {}
16
+ if PlanMyStuff.configuration.issue_fields_enabled
17
+ to_update[:issue_fields] = { 'Issue Status' => 'Fixed' }
18
+ end
19
+ issue.update!(state: :closed, **to_update)
20
+
21
+ yield(issue) if block_given?
22
+ return if performed?
23
+
24
+ flash[:success] = 'Issue was successfully closed.'
25
+ redirect_to(plan_my_stuff.issue_path(issue))
26
+ rescue PlanMyStuff::Error, ArgumentError => e
27
+ pms_handle_rescue(e)
28
+ flash[:error] = e.message
29
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
30
+ end
31
+
32
+ # DELETE /issues/:issue_id/closure
33
+ def destroy
34
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
35
+ to_update = {}
36
+ if PlanMyStuff.configuration.issue_fields_enabled
37
+ to_update[:issue_fields] = { 'Issue Status' => 'Reopened' }
38
+ end
39
+ issue.update!(state: :open, **to_update)
40
+
41
+ yield(issue) if block_given?
42
+ return if performed?
43
+
44
+ flash[:success] = 'Issue was successfully reopened.'
45
+ redirect_to(plan_my_stuff.issue_path(issue))
46
+ rescue PlanMyStuff::Error, ArgumentError => e
47
+ pms_handle_rescue(e)
48
+ flash[:error] = e.message
49
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # CRUD for ticket relationships: +:related+ (metadata-backed) and +:blocked_by+ / +:parent+ / +:sub_ticket+ /
6
+ # +:duplicate_of+ (native GitHub APIs). Backs the links panel on the issue show view.
7
+ #
8
+ # POST /issues/:issue_id/links -> create (adds a link)
9
+ # DELETE /issues/:issue_id/links/:id -> destroy (removes a link)
10
+ #
11
+ class LinksController < PlanMyStuff::ApplicationController
12
+ NATIVE_DISPATCH = {
13
+ 'related' => { add: :add_related!, remove: :remove_related! },
14
+ 'blocked_by' => { add: :add_blocker!, remove: :remove_blocker! },
15
+ 'sub_ticket' => { add: :add_sub_issue!, remove: :remove_sub_issue! },
16
+ 'parent' => { add: :set_parent!, remove: :remove_parent! },
17
+ 'duplicate_of' => { add: :mark_duplicate! },
18
+ }.freeze
19
+
20
+ # POST /issues/:issue_id/links
21
+ def create
22
+ type = link_params[:type].to_s
23
+ unless dispatch_allowed?(type, :add)
24
+ redirect_to_unauthorized(plan_my_stuff.issue_path(params[:issue_id]))
25
+ return
26
+ end
27
+
28
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
29
+ link = add_link(issue, type)
30
+
31
+ yield(issue) if block_given?
32
+ return if performed?
33
+
34
+ flash[:success] = "Linked #{link}"
35
+ redirect_to(plan_my_stuff.issue_path(issue))
36
+ rescue PlanMyStuff::ValidationError, ActiveModel::ValidationError, ArgumentError => e
37
+ pms_handle_rescue(e)
38
+ flash[:error] = e.message
39
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
40
+ end
41
+
42
+ # DELETE /issues/:issue_id/links/:id
43
+ def destroy
44
+ type, repo, number = parse_composite_id(params[:id])
45
+ unless dispatch_allowed?(type, :remove)
46
+ redirect_to_unauthorized(plan_my_stuff.issue_path(params[:issue_id]))
47
+ return
48
+ end
49
+
50
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
51
+ remove_link(issue, type, repo: repo, number: number)
52
+
53
+ yield(issue) if block_given?
54
+ return if performed?
55
+
56
+ flash[:success] = "Unlinked #{repo}##{number}"
57
+ redirect_to(plan_my_stuff.issue_path(issue))
58
+ rescue PlanMyStuff::ValidationError, ActiveModel::ValidationError, ArgumentError => e
59
+ pms_handle_rescue(e)
60
+ flash[:error] = e.message
61
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
62
+ end
63
+
64
+ private
65
+
66
+ # @return [ActionController::Parameters]
67
+ def link_params
68
+ params.require(:link).permit(:type, :issue_number, :repo)
69
+ end
70
+
71
+ # All link mutations require a support user. +destroy+ is not defined for +:duplicate_of+ (reopen via GitHub).
72
+ #
73
+ # @return [Boolean]
74
+ #
75
+ def dispatch_allowed?(type, action)
76
+ entry = NATIVE_DISPATCH[type]
77
+ return false if entry.nil? || entry[action].nil?
78
+
79
+ support_user?
80
+ end
81
+
82
+ # @return [PlanMyStuff::Link]
83
+ def add_link(issue, type)
84
+ method = NATIVE_DISPATCH.dig(type, :add)
85
+ target = {
86
+ type: type,
87
+ issue_number: link_params[:issue_number].to_i,
88
+ repo: link_params[:repo].presence || issue.repo.to_s,
89
+ }
90
+ case type
91
+ when 'related', 'duplicate_of'
92
+ issue.public_send(method, target, user: pms_current_user)
93
+ else
94
+ issue.public_send(method, target)
95
+ end
96
+ end
97
+
98
+ # @return [void]
99
+ def remove_link(issue, type, repo:, number:)
100
+ method = NATIVE_DISPATCH.dig(type, :remove)
101
+ target = { type: type, issue_number: number, repo: repo }
102
+ case type
103
+ when 'parent'
104
+ issue.public_send(method)
105
+ when 'related'
106
+ issue.public_send(method, target, user: pms_current_user)
107
+ else
108
+ issue.public_send(method, target)
109
+ end
110
+ end
111
+
112
+ # Parses +"{type}:{owner/repo}:{number}"+ out of +params[:id]+. The route helper URL-encodes outgoing segments,
113
+ # but Rack's default path decoder intentionally leaves +%2F+ escaped in path params (anti-path-traversal), so
114
+ # the repo portion can still contain +%2F+. Decode before splitting.
115
+ #
116
+ # @param id [String]
117
+ #
118
+ # @return [Array(String, String, Integer)] type, repo, number
119
+ #
120
+ def parse_composite_id(id)
121
+ decoded = CGI.unescape(id.to_s)
122
+ type, repo, number = decoded.split(':', 3)
123
+ raise(ArgumentError, "Invalid link id: #{id.inspect}") if number.blank?
124
+
125
+ [type, repo, number.to_i]
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # Moves an issue's pipeline ProjectItem to "Started" via +PlanMyStuff::Pipeline.take!+. Backs the "Take" button on
6
+ # the mounted issue show view (T-RC-017). The primary UI path for a dev picking up work on an issue they've been
7
+ # assigned to.
8
+ #
9
+ class TakesController < PlanMyStuff::ApplicationController
10
+ before_action :require_support_user
11
+
12
+ # POST /issues/:issue_id/take
13
+ def create
14
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
15
+ guard_already_taken!(issue)
16
+
17
+ project_item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue.number)
18
+ project_item ||= add_to_pipeline(issue)
19
+
20
+ PlanMyStuff::Pipeline.take!(project_item, user: pms_current_user)
21
+ assign_current_user(project_item)
22
+
23
+ yield(project_item) if block_given?
24
+ return if performed?
25
+
26
+ flash[:success] ||= "Issue ##{issue.number} taken."
27
+
28
+ redirect_to(plan_my_stuff.issue_path(issue))
29
+ rescue ArgumentError, PlanMyStuff::Error => e
30
+ pms_handle_rescue(e)
31
+ flash[:error] = e.message
32
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
33
+ end
34
+
35
+ # DELETE /issues/:issue_id/take
36
+ def destroy
37
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
38
+ login = current_user_login
39
+ guard_release!(issue, login)
40
+
41
+ remaining = issue.assignees - [login]
42
+ project_item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue.number)
43
+
44
+ if project_item.present?
45
+ if remaining.empty?
46
+ issue.update!(assignees: [])
47
+ project_item.destroy!
48
+ else
49
+ project_item.assign!(remaining)
50
+ end
51
+ else
52
+ issue.update!(assignees: remaining)
53
+ end
54
+
55
+ yielded = project_item.presence || issue
56
+ yield(yielded) if block_given?
57
+ return if performed?
58
+
59
+ flash[:success] = "Issue ##{issue.number} released."
60
+ redirect_to(plan_my_stuff.issue_path(issue))
61
+ rescue ArgumentError, PlanMyStuff::Error => e
62
+ pms_handle_rescue(e)
63
+ flash[:error] = e.message
64
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
65
+ end
66
+
67
+ private
68
+
69
+ # Redirects non-support users back to the issue page. Mirrors +Issues::ViewersController+'s authorization check.
70
+ #
71
+ # @return [void]
72
+ #
73
+ def require_support_user
74
+ return if support_user?
75
+
76
+ redirect_to_unauthorized(
77
+ plan_my_stuff.issue_path(params[:issue_id]),
78
+ )
79
+ end
80
+
81
+ # Best-effort race guard for two users clicking Take on the same issue. GitHub allows multiple assignees so a
82
+ # naive second take would silently pile on; refuse instead and let the second user see who already has it.
83
+ #
84
+ # @raise [PlanMyStuff::Error] if the issue already has assignees
85
+ #
86
+ # @param issue [PlanMyStuff::Issue]
87
+ #
88
+ # @return [void]
89
+ #
90
+ def guard_already_taken!(issue)
91
+ return if issue.assignees.blank?
92
+
93
+ raise(
94
+ PlanMyStuff::Error,
95
+ "Issue ##{issue.number} is already taken by @#{issue.assignees.join(', @')}.",
96
+ )
97
+ end
98
+
99
+ # Adds an issue to the pipeline project so +Pipeline.take!+ has something to operate on when the user clicks
100
+ # "Take" on an issue that hasn't been submitted to the pipeline yet.
101
+ #
102
+ # @param issue [PlanMyStuff::Issue]
103
+ #
104
+ # @return [PlanMyStuff::ProjectItem]
105
+ #
106
+ def add_to_pipeline(issue)
107
+ pipeline_number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!
108
+ PlanMyStuff::ProjectItem.create!(issue, project_number: pipeline_number)
109
+ end
110
+
111
+ # Looks up the current user's GitHub login in +config.github_login_for+ (a +{user_id => login}+ hash) and
112
+ # assigns them on the project item. Assignment failures are logged and surfaced as a flash warning but do not
113
+ # revert the status move.
114
+ #
115
+ # @param project_item [PlanMyStuff::ProjectItem]
116
+ #
117
+ # @return [void]
118
+ #
119
+ def assign_current_user(project_item)
120
+ login = current_user_login
121
+
122
+ if login.blank?
123
+ Rails.logger.info('[PlanMyStuff] No github_login_for mapping for current user; skipping Take assignment')
124
+ return
125
+ end
126
+
127
+ project_item.assign!(login)
128
+ rescue PlanMyStuff::Error => e
129
+ Rails.logger.warn("[PlanMyStuff] Take assignment failed: #{e.message}")
130
+ flash[:warning] = "Issue taken but assignment failed: #{e.message}"
131
+ end
132
+
133
+ # GitHub login for the current user via +config.github_login_for+ (a +{user_id => login}+ hash), or +nil+ if the
134
+ # user is unmapped or unauthenticated.
135
+ #
136
+ # @return [String, nil]
137
+ #
138
+ def current_user_login
139
+ user = pms_current_user
140
+ user_id = user.present? ? PlanMyStuff::UserResolver.user_id(user) : nil
141
+ PlanMyStuff.configuration.github_login_for[user_id]
142
+ end
143
+
144
+ # Refuses the release when the current user has no login mapping or is not in the issue's assignees. Mirrors the
145
+ # +guard_already_taken!+ check on +#create+.
146
+ #
147
+ # @raise [PlanMyStuff::Error] if the user cannot release the issue
148
+ #
149
+ # @param issue [PlanMyStuff::Issue]
150
+ # @param login [String, nil]
151
+ #
152
+ # @return [void]
153
+ #
154
+ def guard_release!(issue, login)
155
+ raise(PlanMyStuff::Error, 'No GitHub login mapping for current user; cannot release.') if login.blank?
156
+
157
+ raise(PlanMyStuff::Error, "Issue ##{issue.number} is not assigned to you.") if issue.assignees.exclude?(login)
158
+ end
159
+ end
160
+ end
161
+ end