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
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module PlanMyStuff
6
+ module Webhooks
7
+ class GithubController < ActionController::API
8
+ before_action :verify_signature!
9
+
10
+ # POST /webhooks/github
11
+ def create
12
+ event = request.headers['X-GitHub-Event']
13
+
14
+ case event
15
+ when 'pull_request'
16
+ handle_pull_request
17
+ when 'issues'
18
+ handle_issues
19
+ when 'projects_v2_item'
20
+ handle_projects_v2_item
21
+ end
22
+
23
+ head(:ok)
24
+ end
25
+
26
+ private
27
+
28
+ # Validates HMAC-SHA256 signature from the X-Hub-Signature-256 header.
29
+ #
30
+ # @return [void]
31
+ #
32
+ def verify_signature!
33
+ body = request.body.read
34
+ request.body.rewind
35
+
36
+ signature = request.headers['X-Hub-Signature-256'].to_s.gsub('sha256=', '')
37
+ if signature.blank?
38
+ head(:unauthorized)
39
+
40
+ return
41
+ end
42
+
43
+ digest = OpenSSL::Digest.new('sha256')
44
+ secret = PlanMyStuff.configuration.webhook_secret
45
+ hmac = OpenSSL::HMAC.hexdigest(digest, secret, body)
46
+
47
+ return if ActiveSupport::SecurityUtils.secure_compare(hmac, signature)
48
+
49
+ head(:unauthorized)
50
+ end
51
+
52
+ # @return [ActionController::Parameters]
53
+ def payload_params
54
+ @payload_params ||=
55
+ begin
56
+ body = request.body.read
57
+ request.body.rewind
58
+ ActionController::Parameters.new(JSON.parse(body))
59
+ end
60
+ end
61
+
62
+ # @return [ActionController::Parameters]
63
+ def pull_request_params
64
+ payload_params.fetch(:pull_request)
65
+ end
66
+
67
+ # @return [ActionController::Parameters]
68
+ def issue_params
69
+ payload_params.fetch(:issue)
70
+ end
71
+
72
+ # @return [void]
73
+ def handle_issues
74
+ action = payload_params.fetch(:action)
75
+
76
+ case action
77
+ when 'assigned'
78
+ handle_issue_assigned
79
+ when 'unassigned'
80
+ handle_issue_unassigned
81
+ end
82
+ end
83
+
84
+ # Adds the issue to the pipeline project at "Submitted" the first
85
+ # time it is assigned. Re-assigns and additional assignees are
86
+ # ignored -- if the issue is already in the pipeline we do not
87
+ # touch its status.
88
+ #
89
+ # @return [void]
90
+ #
91
+ def handle_issue_assigned
92
+ issue_number = issue_params.fetch(:number)
93
+ assignee_login = payload_params.dig(:assignee, :login)
94
+
95
+ return if assignee_login.blank?
96
+
97
+ existing = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
98
+ if existing.present?
99
+ Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} already in pipeline project, skipping submit!")
100
+
101
+ return
102
+ end
103
+
104
+ repo = payload_params.dig(:repository, :full_name)
105
+ issue = PlanMyStuff::Issue.find(issue_number, repo: repo)
106
+ PlanMyStuff::Pipeline.submit!(issue, assignee: assignee_login)
107
+ end
108
+
109
+ # Removes the issue from the pipeline project when the LAST
110
+ # assignee is removed. If any assignees remain, the webhook is
111
+ # a no-op (only one of N was removed). If the issue isn't in
112
+ # the pipeline at all, also a no-op (logged at info).
113
+ #
114
+ # @return [void]
115
+ #
116
+ def handle_issue_unassigned
117
+ remaining_assignees = Array.wrap(issue_params[:assignees])
118
+ return if remaining_assignees.any?
119
+
120
+ issue_number = issue_params.fetch(:number)
121
+ project_item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
122
+
123
+ if project_item.nil?
124
+ Rails.logger.info(
125
+ "[PlanMyStuff] Issue ##{issue_number} not in pipeline project, skipping remove!",
126
+ )
127
+
128
+ return
129
+ end
130
+
131
+ PlanMyStuff::Pipeline.remove!(project_item)
132
+ end
133
+
134
+ # Handles GitHub's +projects_v2_item+ webhook event. Mirrors a dev
135
+ # dragging an item from "Submitted" to "Started" on the project
136
+ # board into a +Pipeline.take!+ call so the
137
+ # +plan_my_stuff.pipeline.started+ event fires.
138
+ #
139
+ # Only acts on +action: 'edited'+ where the Status single-select
140
+ # field changed on the pipeline project. No-ops when the new
141
+ # status is anything other than "Started", when the +from+ status
142
+ # is already "Started" (loop guard from +move_to!+ triggering
143
+ # another webhook), or when the item cannot be located on the
144
+ # pipeline project.
145
+ #
146
+ # @return [void]
147
+ #
148
+ def handle_projects_v2_item
149
+ return unless payload_params[:action] == 'edited'
150
+
151
+ item_project_node_id = payload_params.dig(:projects_v2_item, :project_node_id)
152
+ return if item_project_node_id.blank?
153
+
154
+ pipeline_number = PlanMyStuff::Pipeline.resolve_pipeline_project_number
155
+ project = PlanMyStuff::Project.find(pipeline_number)
156
+ return unless project.id == item_project_node_id
157
+
158
+ field_value = payload_params.dig(:changes, :field_value)
159
+ return if field_value.blank?
160
+
161
+ return unless field_value[:field_name] == 'Status'
162
+
163
+ started_name = PlanMyStuff::Pipeline.resolve_status_name(
164
+ PlanMyStuff::Pipeline::Status::STARTED,
165
+ )
166
+
167
+ to_name = field_value.dig(:to, :name)
168
+ from_name = field_value.dig(:from, :name)
169
+
170
+ # Moved from 'Started'
171
+ return if from_name == started_name
172
+
173
+ # Moved to something other than 'Started'
174
+ return unless to_name == started_name
175
+
176
+ item_node_id = payload_params.dig(:projects_v2_item, :node_id)
177
+ return if item_node_id.blank?
178
+
179
+ project_item = project.items.find { |item| item.id == item_node_id }
180
+
181
+ if project_item.nil?
182
+ Rails.logger.info(
183
+ "[PlanMyStuff] projects_v2_item #{item_node_id} not found in pipeline project, skipping take!",
184
+ )
185
+
186
+ return
187
+ end
188
+
189
+ PlanMyStuff::Pipeline.take!(project_item)
190
+ end
191
+
192
+ # @return [void]
193
+ def handle_pull_request
194
+ action = payload_params.fetch(:action)
195
+
196
+ # Only PRs targeting +main+ drive pipeline transitions on
197
+ # open/draft/reopen. PRs into +production+ are deploy PRs
198
+ # (often auto-created as drafts) and must not bump items
199
+ # back to "Started". The +closed+ branch handles its own
200
+ # base-ref routing.
201
+ if %w[opened ready_for_review reopened converted_to_draft].include?(action)
202
+ base_ref = pull_request_params.dig(:base, :ref)
203
+ return unless base_ref == PlanMyStuff.configuration.main_branch
204
+ end
205
+
206
+ case action
207
+ when 'opened'
208
+ pull_request_params[:draft] ? handle_converted_to_draft : handle_ready_for_review
209
+ when 'ready_for_review', 'reopened'
210
+ handle_ready_for_review
211
+ when 'converted_to_draft'
212
+ handle_converted_to_draft
213
+ when 'closed'
214
+ handle_closed
215
+ end
216
+ end
217
+
218
+ # @return [void]
219
+ def handle_ready_for_review
220
+ each_linked_item do |item|
221
+ PlanMyStuff::Pipeline.mark_in_review!(item)
222
+ end
223
+ end
224
+
225
+ # @return [void]
226
+ def handle_converted_to_draft
227
+ each_linked_item do |item|
228
+ PlanMyStuff::Pipeline.take!(item)
229
+ end
230
+ end
231
+
232
+ # @return [void]
233
+ def handle_closed
234
+ return unless pull_request_params[:merged]
235
+
236
+ base_ref = pull_request_params.dig(:base, :ref)
237
+ config = PlanMyStuff.configuration
238
+
239
+ if base_ref == config.main_branch
240
+ each_linked_item do |item|
241
+ PlanMyStuff::Pipeline.mark_ready_for_release!(item)
242
+ end
243
+ elsif base_ref == config.production_branch
244
+ PlanMyStuff::Pipeline.start_deployment!(commit_sha: pull_request_params[:merge_commit_sha])
245
+ end
246
+ end
247
+
248
+ # @yield [PlanMyStuff::ProjectItem]
249
+ def each_linked_item
250
+ messages = fetch_commit_messages
251
+ numbers = PlanMyStuff::Pipeline::IssueLinker.extract_issue_numbers(
252
+ pull_request_params,
253
+ commit_messages: messages,
254
+ )
255
+
256
+ numbers.each do |issue_number|
257
+ item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
258
+
259
+ if item
260
+ yield(item)
261
+ else
262
+ Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} not found in pipeline project, skipping")
263
+ end
264
+ end
265
+ end
266
+
267
+ # Fetches commit messages for the current PR via the GitHub API.
268
+ #
269
+ # @return [Array<String>] commit messages (empty on failure)
270
+ #
271
+ def fetch_commit_messages
272
+ repo = payload_params.dig(:repository, :full_name)
273
+ pr_number = pull_request_params[:number]
274
+
275
+ # TODO? Add a PR class
276
+ commits = PlanMyStuff.client.rest(:pull_request_commits, repo, pr_number)
277
+ commits.map { |c| c[:commit][:message] }
278
+ rescue PlanMyStuff::APIError => e
279
+ Rails.logger.warn("[PlanMyStuff] Failed to fetch PR commits: #{e.message}")
280
+ []
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Base class for all PMS gem jobs. Subclasses +::ActiveJob::Base+ so
5
+ # the gem doesn't depend on the consuming app having an
6
+ # +ApplicationJob+ constant.
7
+ class ApplicationJob < ::ActiveJob::Base
8
+ end
9
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Daily-cadence sweep job for reminder dispatch + inactivity auto-close.
5
+ # Consuming apps enqueue it once; the job self-requeues after each
6
+ # perform so the schedule persists without requiring a cron/whenever
7
+ # setup. Queue-adapter-agnostic: runs through ActiveJob's
8
+ # +set(wait_until:).perform_later+ so any backend works.
9
+ class RemindersSweepJob < PlanMyStuff::ApplicationJob
10
+ queue_as :default
11
+
12
+ # Only try once per enqueue. Without this, Delayed-style adapters
13
+ # retry up to 25 times on error and our +around_perform+ +ensure+
14
+ # re-enqueues a follow-up run on every attempt, causing geometric
15
+ # duplicate pile-up. With +attempts: 1+, exactly one perform per
16
+ # +perform_later+; if it fails, the follow-up scheduled in +ensure+
17
+ # takes over tomorrow.
18
+ retry_on StandardError, attempts: 1
19
+
20
+ around_perform :requeue_for_next_run
21
+
22
+ class << self
23
+ # Next sweep time. Default: 6:30am Eastern tomorrow (today if the
24
+ # current time is before 6:30am ET). Apps wanting a different
25
+ # cadence override this on a subclass.
26
+ #
27
+ # @return [Time] UTC
28
+ #
29
+ def next_run
30
+ now = Time.current.in_time_zone('Eastern Time (US & Canada)')
31
+ target = now.change(hour: 6, min: 30, sec: 0)
32
+ target += 1.day if target <= now
33
+ target.utc
34
+ end
35
+
36
+ # Schedules a sweep for +next_run+. Used by the after-perform
37
+ # self-requeue and the +plan_my_stuff:reminders:sweep+ rake task so
38
+ # both kick off runs on the same cadence (instead of "now").
39
+ #
40
+ # @param repo [Symbol, String]
41
+ #
42
+ # @return [ActiveJob::Base]
43
+ #
44
+ def requeue(repo)
45
+ set(wait_until: next_run).perform_later(repo)
46
+ end
47
+ end
48
+
49
+ # @param repo [Symbol, String]
50
+ #
51
+ # @return [void]
52
+ #
53
+ def perform(repo)
54
+ @repo_arg = repo
55
+ PlanMyStuff::Reminders::Sweep.new(repo: repo).call
56
+ PlanMyStuff::Archive::Sweep.new(repo: repo).call
57
+ end
58
+
59
+ private
60
+
61
+ # Runs inside +around_perform+. Re-enqueues the next run in an
62
+ # +ensure+ so a perform error still schedules the next one. Skips
63
+ # requeue when perform never captured a repo arg (deserialization
64
+ # error or direct +.perform+ call with missing kwargs).
65
+ #
66
+ # @return [void]
67
+ #
68
+ def requeue_for_next_run
69
+ yield
70
+ ensure
71
+ enqueue_next_run
72
+ end
73
+
74
+ # @return [void]
75
+ def enqueue_next_run
76
+ return if @repo_arg.nil?
77
+
78
+ self.class.requeue(@repo_arg)
79
+ end
80
+ end
81
+ end
@@ -24,6 +24,13 @@
24
24
  )
25
25
  %>
26
26
  </div>
27
+
28
+ <% unless persisted %>
29
+ <div>
30
+ <%= form.check_box(:waiting_on_reply) %>
31
+ <%= form.label(:waiting_on_reply, 'Waiting on reply') %>
32
+ </div>
33
+ <% end %>
27
34
  <% end %>
28
35
 
29
36
  <div>
@@ -0,0 +1,87 @@
1
+ <%
2
+ approvers = issue.approvers
3
+ pending_count = issue.pending_approvals.size
4
+ current_user_id = current_user_id_local
5
+ %>
6
+
7
+ <% if issue.approvals_required? || support_user %>
8
+ <section aria-label="Manager approvals">
9
+ <h3>Approvals</h3>
10
+
11
+ <% if issue.approvals_required? %>
12
+ <% if issue.fully_approved? %>
13
+ <p><strong>Fully approved</strong></p>
14
+ <% else %>
15
+ <p><strong><%= pending_count %> of <%= approvers.size %> approval(s) pending</strong></p>
16
+ <% end %>
17
+
18
+ <ul>
19
+ <% approvers.each do |approval| %>
20
+ <li>
21
+ <%= PlanMyStuff::UserResolver.display_name(approval.user) %> &mdash;
22
+ <% if approval.approved? %>
23
+ approved
24
+ <% if approval.approved_at %>
25
+ at <%= approval.approved_at.iso8601 %>
26
+ <% end %>
27
+ <% else %>
28
+ pending
29
+ <% end %>
30
+
31
+ <% if approval.pending? && approval.user_id == current_user_id %>
32
+ <%=
33
+ button_to(
34
+ 'Approve',
35
+ plan_my_stuff.issue_approval_path(issue.number, approval.user_id, repo: issue.repo.full_name),
36
+ method: :patch,
37
+ params: { approval: { status: 'approved' } },
38
+ form: { style: 'display:inline' },
39
+ )
40
+ %>
41
+ <% end %>
42
+
43
+ <% if approval.approved? && (approval.user_id == current_user_id || support_user) %>
44
+ <%=
45
+ button_to(
46
+ 'Revoke',
47
+ plan_my_stuff.issue_approval_path(issue.number, approval.user_id, repo: issue.repo.full_name),
48
+ method: :patch,
49
+ params: { approval: { status: 'pending' } },
50
+ form: { style: 'display:inline' },
51
+ )
52
+ %>
53
+ <% end %>
54
+
55
+ <% if support_user %>
56
+ <%=
57
+ button_to(
58
+ 'Remove',
59
+ plan_my_stuff.issue_approval_path(issue.number, approval.user_id, repo: issue.repo.full_name),
60
+ method: :delete,
61
+ form: { style: 'display:inline' },
62
+ )
63
+ %>
64
+ <% end %>
65
+ </li>
66
+ <% end %>
67
+ </ul>
68
+ <% else %>
69
+ <p><em>No approvers required</em></p>
70
+ <% end %>
71
+
72
+ <% if support_user %>
73
+ <%=
74
+ form_with(
75
+ scope: :approval,
76
+ url: plan_my_stuff.issue_approvals_path(issue.number, repo: issue.repo.full_name),
77
+ method: :post,
78
+ local: true,
79
+ ) do |form|
80
+ %>
81
+ <%= form.label(:user_ids, 'Add approver IDs (comma-separated)') %>
82
+ <%= form.text_field(:user_ids) %>
83
+ <%= form.submit('Add Approvers') %>
84
+ <% end %>
85
+ <% end %>
86
+ </section>
87
+ <% end %>
@@ -5,7 +5,7 @@
5
5
  <%=
6
6
  button_to(
7
7
  'x',
8
- plan_my_stuff.issue_remove_label_path(issue.number, label),
8
+ plan_my_stuff.issue_label_path(issue.number, label),
9
9
  method: :delete,
10
10
  form: { style: 'display:inline' }
11
11
  )
@@ -16,7 +16,7 @@
16
16
  <em>No labels</em>
17
17
  <% end %>
18
18
 
19
- <%= form_with(url: plan_my_stuff.issue_add_label_path(issue.number), method: :post, local: true) do |form| %>
19
+ <%= form_with(url: plan_my_stuff.issue_labels_path(issue.number), method: :post, local: true) do |form| %>
20
20
  <%= form.label(:label_name, 'Add label') %>
21
21
  <%= form.text_field(:label_name) %>
22
22
  <%= form.submit('Add') %>
@@ -0,0 +1,70 @@
1
+ <%
2
+ # read_only sections have no add form; remove_only sections allow X but
3
+ # no add (blocking is created from the blocked side; duplicate_of is
4
+ # created via mark_duplicate! and cleared by reopening on GitHub).
5
+ sections = [
6
+ { type: 'parent', label: 'Parent', single: true, add_form: true, removable: true, targets: [issue.parent].compact },
7
+ { type: 'sub_ticket', label: 'Sub-tickets', single: false, add_form: true, removable: true, targets: issue.sub_tickets },
8
+ { type: 'blocked_by', label: 'Blocked by', single: false, add_form: true, removable: true, targets: issue.blocked_by },
9
+ { type: 'blocking', label: 'Blocking', single: false, add_form: false, removable: false, targets: issue.blocking },
10
+ { type: 'related', label: 'Related', single: false, add_form: true, removable: true, targets: issue.related },
11
+ { type: 'duplicate_of', label: 'Duplicate of', single: true, add_form: false, removable: false, targets: [issue.duplicate_of].compact },
12
+ ]
13
+ visible_sections = support_user ? sections : sections.select { |s| s[:type] == 'related' }
14
+ %>
15
+
16
+ <section aria-label="Ticket links">
17
+ <h3>Links</h3>
18
+ <% visible_sections.each do |section| %>
19
+ <div>
20
+ <h4><%= section[:label] %></h4>
21
+ <% if section[:targets].any? %>
22
+ <ul>
23
+ <% section[:targets].each do |target| %>
24
+ <li>
25
+ <%=
26
+ link_to(
27
+ "#{target.repo.full_name}##{target.number} - #{target.title}",
28
+ plan_my_stuff.issue_path(target.number, repo: target.repo.full_name),
29
+ )
30
+ %>
31
+ <% if section[:removable] %>
32
+ <%
33
+ link_id = "#{section[:type]}:#{target.repo.full_name}:#{target.number}"
34
+ %>
35
+ <%=
36
+ button_to(
37
+ 'Remove',
38
+ plan_my_stuff.issue_link_path(issue.number, link_id, repo: issue.repo.full_name),
39
+ method: :delete,
40
+ form: { style: 'display:inline' },
41
+ )
42
+ %>
43
+ <% end %>
44
+ </li>
45
+ <% end %>
46
+ </ul>
47
+ <% else %>
48
+ <p><em>None</em></p>
49
+ <% end %>
50
+
51
+ <% if section[:add_form] && (!section[:single] || section[:targets].empty?) %>
52
+ <%=
53
+ form_with(
54
+ scope: :link,
55
+ url: plan_my_stuff.issue_links_path(issue.number, repo: issue.repo.full_name),
56
+ method: :post,
57
+ local: true,
58
+ ) do |form|
59
+ %>
60
+ <%= form.hidden_field(:type, value: section[:type]) %>
61
+ <%= form.label(:issue_number, 'Issue #') %>
62
+ <%= form.number_field(:issue_number) %>
63
+ <%= form.label(:repo, 'Repo (optional)') %>
64
+ <%= form.text_field(:repo, placeholder: issue.repo.full_name) %>
65
+ <%= form.submit("Add #{section[:label].downcase}") %>
66
+ <% end %>
67
+ <% end %>
68
+ </div>
69
+ <% end %>
70
+ </section>
@@ -9,7 +9,7 @@
9
9
  <%=
10
10
  button_to(
11
11
  'Remove',
12
- plan_my_stuff.remove_viewer_issue_path(issue.number, viewer_id: viewer_id, repo: issue.repo.full_name),
12
+ plan_my_stuff.issue_viewer_path(issue.number, viewer_id, repo: issue.repo.full_name),
13
13
  method: :delete
14
14
  )
15
15
  %>
@@ -20,7 +20,7 @@
20
20
  <p>No viewers added.</p>
21
21
  <% end %>
22
22
 
23
- <%= form_with(url: plan_my_stuff.add_viewers_issue_path(issue.number, repo: issue.repo.full_name), method: :post) do |form| %>
23
+ <%= form_with(url: plan_my_stuff.issue_viewers_path(issue.number, repo: issue.repo.full_name), method: :post) do |form| %>
24
24
  <div>
25
25
  <%= form.label(:viewer_ids, 'Add viewer IDs (comma-separated)') %>
26
26
  <%= form.text_field(:viewer_ids) %>
@@ -2,10 +2,26 @@
2
2
 
3
3
  <p>
4
4
  <%= link_to('Edit', plan_my_stuff.edit_issue_path(@issue.number, repo: @issue.repo.full_name)) %>
5
+ <% if @support_user && @issue.html_url.present? %>
6
+ <%= link_to('View on GitHub', @issue.html_url, target: '_blank', rel: 'noopener') %>
7
+ <% end %>
5
8
  <% if @issue.state == 'open' %>
6
- <%= button_to('Close Issue', plan_my_stuff.close_issue_path(@issue.number, repo: @issue.repo.full_name), method: :patch) %>
9
+ <%= button_to('Close Issue', plan_my_stuff.issue_closure_path(@issue.number, repo: @issue.repo.full_name), method: :post) %>
7
10
  <% else %>
8
- <%= button_to('Reopen Issue', plan_my_stuff.reopen_issue_path(@issue.number, repo: @issue.repo.full_name), method: :patch) %>
11
+ <%= button_to('Reopen Issue', plan_my_stuff.issue_closure_path(@issue.number, repo: @issue.repo.full_name), method: :delete) %>
12
+ <% end %>
13
+ <% if @support_user && @issue.state == 'open' %>
14
+ <% if @issue.metadata.waiting_on_user_at.present? %>
15
+ <%= button_to('Mark replied', plan_my_stuff.issue_waiting_path(@issue.number, repo: @issue.repo.full_name), method: :delete) %>
16
+ <% else %>
17
+ <%= button_to('Mark waiting', plan_my_stuff.issue_waiting_path(@issue.number, repo: @issue.repo.full_name), method: :post) %>
18
+ <% end %>
19
+ <% end %>
20
+ <% if @support_user && @pipeline_enabled && (@pipeline_item.nil? || @pipeline_item.status == PlanMyStuff::Pipeline.resolve_status_name(PlanMyStuff::Pipeline::Status::SUBMITTED)) %>
21
+ <%= button_to('Take', plan_my_stuff.issue_take_path(@issue.number, repo: @issue.repo.full_name), method: :post) %>
22
+ <% end %>
23
+ <% if @support_user %>
24
+ <%= link_to('Start Testing Project', plan_my_stuff.new_testing_project_path(subject_url: @issue.html_url)) %>
9
25
  <% end %>
10
26
  </p>
11
27
 
@@ -27,6 +43,28 @@
27
43
 
28
44
  <hr>
29
45
 
46
+ <%=
47
+ render({
48
+ partial: 'plan_my_stuff/issues/partials/links',
49
+ locals: { issue: @issue, support_user: @support_user },
50
+ })
51
+ %>
52
+
53
+ <hr>
54
+
55
+ <%=
56
+ render({
57
+ partial: 'plan_my_stuff/issues/partials/approvals',
58
+ locals: {
59
+ issue: @issue,
60
+ support_user: @support_user,
61
+ current_user_id_local: @current_user_id,
62
+ },
63
+ })
64
+ %>
65
+
66
+ <hr>
67
+
30
68
  <h2>Comments (<%= @comments.size %>)</h2>
31
69
 
32
70
  <% if @comments.any? %>
@@ -38,6 +76,11 @@
38
76
  <%= link_to('Edit', plan_my_stuff.edit_issue_comment_path(@issue.number, comment.id, repo: @issue.repo.full_name)) %>
39
77
  </p>
40
78
  <% end %>
79
+ <% if @support_user && comment.html_url.present? %>
80
+ <p>
81
+ <%= link_to('View on GitHub', comment.html_url, target: '_blank', rel: 'noopener') %>
82
+ </p>
83
+ <% end %>
41
84
  </div>
42
85
  <% end %>
43
86
  <% else %>
@@ -51,7 +94,7 @@
51
94
  partial: 'plan_my_stuff/comments/partials/form',
52
95
  locals: {
53
96
  issue: @issue,
54
- comment: PlanMyStuff::Comment.new,
97
+ comment: PlanMyStuff::Comment.new(visibility: :public),
55
98
  support_user: @support_user,
56
99
  },
57
100
  })
@@ -1,10 +1,21 @@
1
1
  <h1>Projects</h1>
2
2
 
3
+ <p>
4
+ <%= link_to('All', plan_my_stuff.projects_path(filter: 'all')) %>
5
+ <%= link_to('Regular', plan_my_stuff.projects_path(filter: 'regular')) %>
6
+ <%= link_to('Testing', plan_my_stuff.projects_path(filter: 'testing')) %>
7
+ </p>
8
+
3
9
  <% if @projects.any? %>
4
10
  <ul>
5
11
  <% @projects.each do |project| %>
6
12
  <li>
7
- <%= link_to(project.title, plan_my_stuff.project_path(project.number)) %>
13
+ <% if project.is_a?(PlanMyStuff::TestingProject) %>
14
+ <%= link_to(project.title, plan_my_stuff.testing_project_path(project.number)) %>
15
+ <span>[Testing]</span>
16
+ <% else %>
17
+ <%= link_to(project.title, plan_my_stuff.project_path(project.number)) %>
18
+ <% end %>
8
19
  </li>
9
20
  <% end %>
10
21
  </ul>
@@ -13,3 +24,6 @@
13
24
  <% end %>
14
25
 
15
26
  <p><%= link_to('New Project', plan_my_stuff.new_project_path) %></p>
27
+ <% if @support_user %>
28
+ <p><%= link_to('New Testing Project', plan_my_stuff.new_testing_project_path) %></p>
29
+ <% end %>