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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +595 -0
- data/CONFIGURATION.md +487 -0
- data/README.md +612 -88
- data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
- data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
- data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
- data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
- data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
- data/app/jobs/plan_my_stuff/application_job.rb +8 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
- data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
- data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
- data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
- data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
- data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +43 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
- data/lib/plan_my_stuff/application_record.rb +158 -1
- data/lib/plan_my_stuff/approval.rb +88 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +12 -0
- data/lib/plan_my_stuff/attachment.rb +83 -0
- data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
- data/lib/plan_my_stuff/base_metadata.rb +25 -28
- data/lib/plan_my_stuff/base_project.rb +502 -0
- data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
- data/lib/plan_my_stuff/base_project_item.rb +588 -0
- data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
- data/lib/plan_my_stuff/cache.rb +197 -0
- data/lib/plan_my_stuff/client.rb +139 -64
- data/lib/plan_my_stuff/comment.rb +225 -100
- data/lib/plan_my_stuff/comment_metadata.rb +68 -5
- data/lib/plan_my_stuff/configuration.rb +459 -28
- data/lib/plan_my_stuff/custom_fields.rb +96 -12
- data/lib/plan_my_stuff/engine.rb +14 -2
- data/lib/plan_my_stuff/errors.rb +65 -5
- data/lib/plan_my_stuff/graphql/queries.rb +454 -0
- data/lib/plan_my_stuff/issue.rb +1097 -166
- data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
- data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
- data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
- data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
- data/lib/plan_my_stuff/issue_field.rb +126 -0
- data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
- data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
- data/lib/plan_my_stuff/issue_metadata.rb +132 -21
- data/lib/plan_my_stuff/label.rb +100 -13
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/markdown.rb +13 -7
- data/lib/plan_my_stuff/metadata_parser.rb +51 -12
- data/lib/plan_my_stuff/notifications.rb +148 -0
- data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +40 -0
- data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
- data/lib/plan_my_stuff/pipeline.rb +310 -0
- data/lib/plan_my_stuff/project.rb +63 -465
- data/lib/plan_my_stuff/project_item.rb +3 -409
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +47 -0
- data/lib/plan_my_stuff/reminders/closer.rb +70 -0
- data/lib/plan_my_stuff/reminders/fire.rb +129 -0
- data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
- data/lib/plan_my_stuff/reminders.rb +12 -0
- data/lib/plan_my_stuff/repo.rb +145 -0
- data/lib/plan_my_stuff/test_helpers.rb +265 -25
- data/lib/plan_my_stuff/testing_project.rb +292 -0
- data/lib/plan_my_stuff/testing_project_item.rb +218 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +24 -3
- data/lib/plan_my_stuff/verifier.rb +10 -0
- data/lib/plan_my_stuff/version.rb +2 -2
- data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
- data/lib/plan_my_stuff.rb +55 -20
- data/lib/tasks/plan_my_stuff.rake +331 -0
- metadata +99 -4
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
class 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
|
-
#
|
|
27
|
-
|
|
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? &&
|
|
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
|
|
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 =
|
|
7
|
+
@issue = PlanMyStuff::Issue.find(params[:issue_id])
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
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
|
-
@
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
44
|
-
rescue
|
|
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:
|
|
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 =
|
|
62
|
-
@comment =
|
|
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
|
-
#
|
|
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 ==
|
|
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
|