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
|
@@ -0,0 +1,371 @@
|
|
|
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 "Started" the first time it is assigned. Re-assigns and
|
|
85
|
+
# additional assignees are ignored -- if the issue is already in the pipeline we do not touch its status.
|
|
86
|
+
#
|
|
87
|
+
# Skipped paths (no project item is created):
|
|
88
|
+
# - Issue is closed (assignment changes on a closed issue must not alter the pipeline)
|
|
89
|
+
# - Issue has pending approvals (creating + +take!+ would either orphan an item or 500 on the approval guard)
|
|
90
|
+
#
|
|
91
|
+
# GitHub already records the issue assignment (that's what triggered this webhook), so the gem does not
|
|
92
|
+
# call +assign!+ on the project item -- that would clobber co-assignees on the underlying issue.
|
|
93
|
+
#
|
|
94
|
+
# Regardless of pipeline state, +mark_responded!+ stamps +metadata.responded_at+ when a support user is the
|
|
95
|
+
# assignee -- including self-assigns to issues already in the pipeline. The method's own guards no-op for
|
|
96
|
+
# non-support assignees and already-responded issues.
|
|
97
|
+
#
|
|
98
|
+
# @return [void]
|
|
99
|
+
#
|
|
100
|
+
def handle_issue_assigned
|
|
101
|
+
return if issue_params[:state] == 'closed'
|
|
102
|
+
|
|
103
|
+
issue_number = issue_params.fetch(:number)
|
|
104
|
+
assignee_login = payload_params.dig(:assignee, :login)
|
|
105
|
+
|
|
106
|
+
return if assignee_login.blank?
|
|
107
|
+
|
|
108
|
+
repo = payload_params.dig(:repository, :full_name)
|
|
109
|
+
issue = PlanMyStuff::Issue.find(issue_number, repo: repo)
|
|
110
|
+
assignee_user = PlanMyStuff::UserResolver.from_github_login(assignee_login)
|
|
111
|
+
|
|
112
|
+
# Stamp before the already-in-pipeline early-return so a support user self-assigning to an
|
|
113
|
+
# already-taken issue still records a first response.
|
|
114
|
+
issue.mark_responded!(assignee_user)
|
|
115
|
+
|
|
116
|
+
existing = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
|
|
117
|
+
if existing.present?
|
|
118
|
+
Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} already in pipeline project, skipping")
|
|
119
|
+
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if issue.approvals_required? && !issue.fully_approved?
|
|
124
|
+
Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} has pending approvals, skipping")
|
|
125
|
+
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!
|
|
130
|
+
project_item = PlanMyStuff::ProjectItem.create!(issue, project_number: number)
|
|
131
|
+
PlanMyStuff::Pipeline.take!(project_item, user: assignee_user)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Removes the issue from the pipeline project when the LAST assignee is removed. If any assignees remain,
|
|
135
|
+
# the webhook is a no-op (only one of N was removed). If the issue isn't in the pipeline at all, also a
|
|
136
|
+
# no-op (logged at info).
|
|
137
|
+
#
|
|
138
|
+
# Closed issues are skipped: assignment changes on a closed issue must not alter the pipeline. Items
|
|
139
|
+
# already on the release cycle (Ready for Release, Release in Progress, Completed) are also locked --
|
|
140
|
+
# once on the release path an item should not come off via webhook.
|
|
141
|
+
#
|
|
142
|
+
# @return [void]
|
|
143
|
+
#
|
|
144
|
+
def handle_issue_unassigned
|
|
145
|
+
return if issue_params[:state] == 'closed'
|
|
146
|
+
|
|
147
|
+
remaining_assignees = Array.wrap(issue_params[:assignees])
|
|
148
|
+
return if remaining_assignees.present?
|
|
149
|
+
|
|
150
|
+
issue_number = issue_params.fetch(:number)
|
|
151
|
+
project_item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
|
|
152
|
+
|
|
153
|
+
if project_item.nil?
|
|
154
|
+
Rails.logger.info(
|
|
155
|
+
"[PlanMyStuff] Issue ##{issue_number} not in pipeline project, skipping remove!",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if PlanMyStuff::Pipeline.release_cycle_locked?(project_item)
|
|
162
|
+
Rails.logger.info(
|
|
163
|
+
"[PlanMyStuff] Issue ##{issue_number} on release cycle (#{project_item.status}), skipping remove!",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
PlanMyStuff::Pipeline.remove!(project_item)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Handles GitHub's +projects_v2_item+ webhook event. Mirrors a dev dragging an item from "Submitted" to
|
|
173
|
+
# "Started" on the project board into a +Pipeline.take!+ call so the +pipeline_started.plan_my_stuff+
|
|
174
|
+
# event fires.
|
|
175
|
+
#
|
|
176
|
+
# Only acts on +action: 'edited'+ where the Status single-select field changed on the pipeline project.
|
|
177
|
+
# No-ops when the new status is anything other than "Started", when the +from+ status is already "Started"
|
|
178
|
+
# (loop guard from +move_to!+ triggering another webhook), or when the item cannot be located on the
|
|
179
|
+
# pipeline project.
|
|
180
|
+
#
|
|
181
|
+
# @return [void]
|
|
182
|
+
#
|
|
183
|
+
def handle_projects_v2_item
|
|
184
|
+
return unless payload_params[:action] == 'edited'
|
|
185
|
+
|
|
186
|
+
item_project_node_id = payload_params.dig(:projects_v2_item, :project_node_id)
|
|
187
|
+
return if item_project_node_id.blank?
|
|
188
|
+
|
|
189
|
+
pipeline_number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!
|
|
190
|
+
project = PlanMyStuff::Project.find(pipeline_number)
|
|
191
|
+
return unless project.id == item_project_node_id
|
|
192
|
+
|
|
193
|
+
field_value = payload_params.dig(:changes, :field_value)
|
|
194
|
+
return if field_value.blank?
|
|
195
|
+
|
|
196
|
+
return unless field_value[:field_name] == 'Status'
|
|
197
|
+
|
|
198
|
+
started_name = PlanMyStuff::Pipeline.resolve_status_name(
|
|
199
|
+
PlanMyStuff::Pipeline::Status::STARTED,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
to_name = field_value.dig(:to, :name)
|
|
203
|
+
from_name = field_value.dig(:from, :name)
|
|
204
|
+
|
|
205
|
+
# Moved from 'Started'
|
|
206
|
+
return if from_name == started_name
|
|
207
|
+
|
|
208
|
+
# Moved to something other than 'Started'
|
|
209
|
+
return unless to_name == started_name
|
|
210
|
+
|
|
211
|
+
item_node_id = payload_params.dig(:projects_v2_item, :node_id)
|
|
212
|
+
return if item_node_id.blank?
|
|
213
|
+
|
|
214
|
+
project_item = project.items.find { |item| item.id == item_node_id }
|
|
215
|
+
|
|
216
|
+
if project_item.nil?
|
|
217
|
+
Rails.logger.info(
|
|
218
|
+
"[PlanMyStuff] projects_v2_item #{item_node_id} not found in pipeline project, skipping take!",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
PlanMyStuff::Pipeline.take!(project_item)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# @return [void]
|
|
228
|
+
def handle_pull_request
|
|
229
|
+
action = payload_params.fetch(:action)
|
|
230
|
+
|
|
231
|
+
# Only PRs targeting +main+ drive pipeline transitions on open/draft/reopen. PRs into +production+ are
|
|
232
|
+
# deploy PRs (often auto-created as drafts) and must not bump items back to "Started". The +closed+
|
|
233
|
+
# branch handles its own base-ref routing.
|
|
234
|
+
if %w[opened ready_for_review reopened converted_to_draft].include?(action)
|
|
235
|
+
base_ref = pull_request_params.dig(:base, :ref)
|
|
236
|
+
return unless base_ref == PlanMyStuff.configuration.main_branch
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
case action
|
|
240
|
+
when 'opened'
|
|
241
|
+
pull_request_params[:draft] ? handle_draft_opened : handle_ready_for_review
|
|
242
|
+
when 'ready_for_review', 'reopened'
|
|
243
|
+
handle_ready_for_review
|
|
244
|
+
when 'converted_to_draft'
|
|
245
|
+
handle_converted_to_draft
|
|
246
|
+
when 'closed'
|
|
247
|
+
handle_closed
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Opening a PR as a draft is a soft "I've started working on this" signal. For each linked issue, when
|
|
252
|
+
# the issue is not yet in the pipeline, add it at "Started"; when the issue has no assignees, assign the
|
|
253
|
+
# PR author. Already-in-pipeline items are NOT moved (a draft open is not a status change).
|
|
254
|
+
#
|
|
255
|
+
# @return [void]
|
|
256
|
+
#
|
|
257
|
+
def handle_draft_opened
|
|
258
|
+
repo = payload_params.dig(:repository, :full_name)
|
|
259
|
+
pr_author = pull_request_params.dig(:user, :login)
|
|
260
|
+
|
|
261
|
+
each_linked_issue_number do |issue_number|
|
|
262
|
+
issue = PlanMyStuff::Issue.find(issue_number, repo: repo)
|
|
263
|
+
|
|
264
|
+
if PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number).nil?
|
|
265
|
+
ensure_in_pipeline_at_started(issue, pr_author)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
assign_pr_author(issue, pr_author) if pr_author.present? && issue.assignees.empty?
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# @return [void]
|
|
273
|
+
def handle_ready_for_review
|
|
274
|
+
each_linked_item do |item|
|
|
275
|
+
PlanMyStuff::Pipeline.mark_in_review!(item)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Adds the issue to the pipeline at "Started". Skipped when the issue has pending approvals
|
|
280
|
+
# (Pipeline.take!'s guard would otherwise leave an orphan project item behind).
|
|
281
|
+
#
|
|
282
|
+
# @param issue [PlanMyStuff::Issue]
|
|
283
|
+
# @param actor_login [String, nil] GitHub login of the webhook actor, mapped to a user via +github_login_for+
|
|
284
|
+
#
|
|
285
|
+
# @return [void]
|
|
286
|
+
#
|
|
287
|
+
def ensure_in_pipeline_at_started(issue, actor_login = nil)
|
|
288
|
+
if issue.approvals_required? && !issue.fully_approved?
|
|
289
|
+
Rails.logger.info("[PlanMyStuff] Issue ##{issue.number} has pending approvals, skipping pipeline add")
|
|
290
|
+
return
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!
|
|
294
|
+
project_item = PlanMyStuff::ProjectItem.create!(issue, project_number: number)
|
|
295
|
+
PlanMyStuff::Pipeline.take!(project_item, user: PlanMyStuff::UserResolver.from_github_login(actor_login))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# @param issue [PlanMyStuff::Issue]
|
|
299
|
+
# @param pr_author [String]
|
|
300
|
+
#
|
|
301
|
+
# @return [void]
|
|
302
|
+
#
|
|
303
|
+
def assign_pr_author(issue, pr_author)
|
|
304
|
+
PlanMyStuff::Issue.update!(number: issue.number, repo: issue.repo, assignees: [pr_author])
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# @return [void]
|
|
308
|
+
def handle_converted_to_draft
|
|
309
|
+
each_linked_item do |item|
|
|
310
|
+
PlanMyStuff::Pipeline.take!(item)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# @return [void]
|
|
315
|
+
def handle_closed
|
|
316
|
+
return unless pull_request_params[:merged]
|
|
317
|
+
|
|
318
|
+
base_ref = pull_request_params.dig(:base, :ref)
|
|
319
|
+
config = PlanMyStuff.configuration
|
|
320
|
+
|
|
321
|
+
if base_ref == config.main_branch
|
|
322
|
+
each_linked_item do |item|
|
|
323
|
+
PlanMyStuff::Pipeline.mark_ready_for_release!(item)
|
|
324
|
+
end
|
|
325
|
+
elsif base_ref == config.production_branch
|
|
326
|
+
PlanMyStuff::Pipeline.start_deployment!(commit_sha: pull_request_params[:merge_commit_sha])
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# @yield [PlanMyStuff::ProjectItem]
|
|
331
|
+
def each_linked_item
|
|
332
|
+
each_linked_issue_number do |issue_number|
|
|
333
|
+
item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue_number)
|
|
334
|
+
|
|
335
|
+
if item
|
|
336
|
+
yield(item)
|
|
337
|
+
else
|
|
338
|
+
Rails.logger.info("[PlanMyStuff] Issue ##{issue_number} not found in pipeline project, skipping")
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# @yield [Integer] linked issue number
|
|
344
|
+
def each_linked_issue_number(&)
|
|
345
|
+
messages = fetch_commit_messages
|
|
346
|
+
numbers = PlanMyStuff::Pipeline::IssueLinker.extract_issue_numbers(
|
|
347
|
+
pull_request_params,
|
|
348
|
+
commit_messages: messages,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
numbers.each(&)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Fetches commit messages for the current PR via the GitHub API.
|
|
355
|
+
#
|
|
356
|
+
# @return [Array<String>] commit messages (empty on failure)
|
|
357
|
+
#
|
|
358
|
+
def fetch_commit_messages
|
|
359
|
+
repo = payload_params.dig(:repository, :full_name)
|
|
360
|
+
pr_number = pull_request_params[:number]
|
|
361
|
+
|
|
362
|
+
# TODO? Add a PR class
|
|
363
|
+
commits = PlanMyStuff.client.rest(:pull_request_commits, repo, pr_number)
|
|
364
|
+
commits.map { |c| c[:commit][:message] }
|
|
365
|
+
rescue PlanMyStuff::APIError => e
|
|
366
|
+
Rails.logger.warn("[PlanMyStuff] Failed to fetch PR commits: #{e.message}")
|
|
367
|
+
[]
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Daily-cadence sweep job for reminder dispatch + inactivity auto-close. Consuming apps enqueue it once; the job
|
|
5
|
+
# self-requeues after each perform so the schedule persists without requiring a cron/whenever setup.
|
|
6
|
+
# Queue-adapter-agnostic: runs through ActiveJob's +set(wait_until:).perform_later+ so any backend works.
|
|
7
|
+
class RemindersSweepJob < PlanMyStuff::ApplicationJob
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
# Only try once per enqueue. Without this, Delayed-style adapters retry up to 25 times on error and our
|
|
11
|
+
# +around_perform+ +ensure+ re-enqueues a follow-up run on every attempt, causing geometric duplicate
|
|
12
|
+
# pile-up. With +attempts: 1+, exactly one perform per +perform_later+; if it fails, the follow-up scheduled
|
|
13
|
+
# in +ensure+ takes over tomorrow.
|
|
14
|
+
retry_on StandardError, attempts: 1
|
|
15
|
+
|
|
16
|
+
around_perform :requeue_for_next_run
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Next sweep time. Default: 6:30am Eastern tomorrow (today if the current time is before 6:30am ET). Apps
|
|
20
|
+
# wanting a different cadence override this on a subclass.
|
|
21
|
+
#
|
|
22
|
+
# @return [Time] UTC
|
|
23
|
+
#
|
|
24
|
+
def next_run
|
|
25
|
+
now = Time.current.in_time_zone('Eastern Time (US & Canada)')
|
|
26
|
+
target = now.change(hour: 6, min: 30, sec: 0)
|
|
27
|
+
target += 1.day if target <= now
|
|
28
|
+
target.utc
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Schedules a sweep for +next_run+. Used by the after-perform self-requeue and the
|
|
32
|
+
# +plan_my_stuff:reminders:sweep+ rake task so both kick off runs on the same cadence (instead of "now").
|
|
33
|
+
#
|
|
34
|
+
# @param repo [Symbol, String]
|
|
35
|
+
#
|
|
36
|
+
# @return [ActiveJob::Base]
|
|
37
|
+
#
|
|
38
|
+
def requeue(repo)
|
|
39
|
+
set(wait_until: next_run).perform_later(repo)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param repo [Symbol, String]
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
#
|
|
47
|
+
def perform(repo)
|
|
48
|
+
@repo_arg = repo
|
|
49
|
+
PlanMyStuff::Reminders::Sweep.new(repo: repo).call
|
|
50
|
+
PlanMyStuff::Archive::Sweep.new(repo: repo).call
|
|
51
|
+
PlanMyStuff::Pipeline::CompletedSweep.perform!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Runs inside +around_perform+. Re-enqueues the next run in an +ensure+ so a perform error still schedules
|
|
57
|
+
# the next one. Skips requeue when perform never captured a repo arg (deserialization error or direct
|
|
58
|
+
# +.perform+ call with missing kwargs).
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
#
|
|
62
|
+
def requeue_for_next_run
|
|
63
|
+
yield
|
|
64
|
+
ensure
|
|
65
|
+
enqueue_next_run
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [void]
|
|
69
|
+
def enqueue_next_run
|
|
70
|
+
return if @repo_arg.nil?
|
|
71
|
+
|
|
72
|
+
self.class.requeue(@repo_arg)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<%# locals: (issue:, comment:, support_user:) %>
|
|
1
2
|
<%
|
|
2
3
|
persisted = comment.persisted?
|
|
3
4
|
url =
|
|
@@ -24,6 +25,13 @@
|
|
|
24
25
|
)
|
|
25
26
|
%>
|
|
26
27
|
</div>
|
|
28
|
+
|
|
29
|
+
<% unless persisted %>
|
|
30
|
+
<div>
|
|
31
|
+
<%= form.check_box(:waiting_on_reply) %>
|
|
32
|
+
<%= form.label(:waiting_on_reply, 'Waiting on reply') %>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
27
35
|
<% end %>
|
|
28
36
|
|
|
29
37
|
<div>
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
<h1>Edit Issue #<%= @issue.number %></h1>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<p style="color: red;"><%= flash[:error] %></p>
|
|
5
|
-
<% end %>
|
|
3
|
+
<%= render({ partial: 'plan_my_stuff/partials/flash' }) %>
|
|
6
4
|
|
|
7
|
-
<%= render({ partial: 'plan_my_stuff/issues/partials/form', locals: { issue: @issue
|
|
5
|
+
<%= render({ partial: 'plan_my_stuff/issues/partials/form', locals: { issue: @issue } }) %>
|
|
8
6
|
|
|
9
7
|
<% if @support_user %>
|
|
10
8
|
<hr>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<h1>Issues</h1>
|
|
2
2
|
|
|
3
|
-
<% if @issues.
|
|
3
|
+
<% if @issues.present? %>
|
|
4
4
|
<table>
|
|
5
5
|
<thead>
|
|
6
6
|
<tr>
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<% @issues.each do |issue| %>
|
|
15
15
|
<tr>
|
|
16
16
|
<td><%= issue.number %></td>
|
|
17
|
-
<td><%= link_to(issue.title, plan_my_stuff.issue_path(issue
|
|
17
|
+
<td><%= link_to(issue.title, plan_my_stuff.issue_path(issue)) %></td>
|
|
18
18
|
<td><%= issue.state %></td>
|
|
19
19
|
<td><%= issue.labels.join(', ') %></td>
|
|
20
20
|
</tr>
|
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
|
|
25
25
|
<nav>
|
|
26
26
|
<% if @page > 1 %>
|
|
27
|
-
<%= link_to('Previous', plan_my_stuff.issues_path(page: @page - 1, state: @state, labels: @labels)) %>
|
|
27
|
+
<%= link_to('Previous', plan_my_stuff.issues_path(page: @page - 1, state: @state, labels: @labels, repo: @repo)) %>
|
|
28
28
|
<% end %>
|
|
29
29
|
<% if @issues.size == @per_page %>
|
|
30
|
-
<%= link_to('Next', plan_my_stuff.issues_path(page: @page + 1, state: @state, labels: @labels)) %>
|
|
30
|
+
<%= link_to('Next', plan_my_stuff.issues_path(page: @page + 1, state: @state, labels: @labels, repo: @repo)) %>
|
|
31
31
|
<% end %>
|
|
32
32
|
</nav>
|
|
33
33
|
<% else %>
|
|
34
34
|
<p>No issues found.</p>
|
|
35
35
|
<% end %>
|
|
36
36
|
<br>
|
|
37
|
-
<%= link_to
|
|
37
|
+
<%= link_to('New Issue', plan_my_stuff.new_issue_path(repo: @repo)) %>
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
<h1>New Issue</h1>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<p style="color: red;"><%= flash[:error] %></p>
|
|
5
|
-
<% end %>
|
|
3
|
+
<%= render({ partial: 'plan_my_stuff/partials/flash' }) %>
|
|
6
4
|
|
|
7
|
-
<%= render({ partial: 'plan_my_stuff/issues/partials/form', locals: { issue: @issue
|
|
5
|
+
<%= render({ partial: 'plan_my_stuff/issues/partials/form', locals: { issue: @issue } }) %>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<%# locals: (issue:, support_user:, current_user_id_local:) %>
|
|
2
|
+
<%
|
|
3
|
+
approvers = issue.approvers
|
|
4
|
+
pending_count = issue.pending_approvals.size
|
|
5
|
+
rejected_count = issue.rejected_approvals.size
|
|
6
|
+
current_user_id = current_user_id_local
|
|
7
|
+
%>
|
|
8
|
+
|
|
9
|
+
<% if issue.approvals_required? || support_user %>
|
|
10
|
+
<section aria-label="Manager approvals">
|
|
11
|
+
<h3>Approvals</h3>
|
|
12
|
+
|
|
13
|
+
<% if issue.approvals_required? %>
|
|
14
|
+
<% if issue.fully_approved? %>
|
|
15
|
+
<p><strong>Fully approved</strong></p>
|
|
16
|
+
<% else %>
|
|
17
|
+
<p>
|
|
18
|
+
<strong><%= pending_count %> of <%= approvers.size %> approval(s) pending</strong>
|
|
19
|
+
<% if rejected_count.positive? %>
|
|
20
|
+
(<%= rejected_count %> rejected)
|
|
21
|
+
<% end %>
|
|
22
|
+
</p>
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<ul>
|
|
26
|
+
<% approvers.each do |approval| %>
|
|
27
|
+
<li>
|
|
28
|
+
<%= PlanMyStuff::UserResolver.display_name(approval.user) %> —
|
|
29
|
+
<% if approval.approved? %>
|
|
30
|
+
approved
|
|
31
|
+
<% if approval.approved_at %>
|
|
32
|
+
at <%= approval.approved_at.iso8601 %>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% elsif approval.rejected? %>
|
|
35
|
+
rejected
|
|
36
|
+
<% if approval.rejected_at %>
|
|
37
|
+
at <%= approval.rejected_at.iso8601 %>
|
|
38
|
+
<% end %>
|
|
39
|
+
<% else %>
|
|
40
|
+
pending
|
|
41
|
+
<% end %>
|
|
42
|
+
|
|
43
|
+
<% if approval.pending? && approval.user_id == current_user_id %>
|
|
44
|
+
<%=
|
|
45
|
+
button_to(
|
|
46
|
+
'Approve',
|
|
47
|
+
plan_my_stuff.issue_approval_path(issue, approval.user_id),
|
|
48
|
+
method: :patch,
|
|
49
|
+
params: { approval: { status: 'approved' } },
|
|
50
|
+
form: { style: 'display:inline' },
|
|
51
|
+
)
|
|
52
|
+
%>
|
|
53
|
+
<%=
|
|
54
|
+
button_to(
|
|
55
|
+
'Reject',
|
|
56
|
+
plan_my_stuff.issue_approval_path(issue, approval.user_id),
|
|
57
|
+
method: :patch,
|
|
58
|
+
params: { approval: { status: 'rejected' } },
|
|
59
|
+
form: { style: 'display:inline' },
|
|
60
|
+
)
|
|
61
|
+
%>
|
|
62
|
+
<% end %>
|
|
63
|
+
|
|
64
|
+
<% if !approval.pending? && (approval.user_id == current_user_id || support_user) %>
|
|
65
|
+
<%=
|
|
66
|
+
button_to(
|
|
67
|
+
'Revoke',
|
|
68
|
+
plan_my_stuff.issue_approval_path(issue, approval.user_id),
|
|
69
|
+
method: :patch,
|
|
70
|
+
params: { approval: { status: 'pending' } },
|
|
71
|
+
form: { style: 'display:inline' },
|
|
72
|
+
)
|
|
73
|
+
%>
|
|
74
|
+
<% end %>
|
|
75
|
+
|
|
76
|
+
<% if support_user %>
|
|
77
|
+
<%=
|
|
78
|
+
button_to(
|
|
79
|
+
'Remove',
|
|
80
|
+
plan_my_stuff.issue_approval_path(issue, approval.user_id),
|
|
81
|
+
method: :delete,
|
|
82
|
+
form: { style: 'display:inline' },
|
|
83
|
+
)
|
|
84
|
+
%>
|
|
85
|
+
<% end %>
|
|
86
|
+
</li>
|
|
87
|
+
<% end %>
|
|
88
|
+
</ul>
|
|
89
|
+
<% else %>
|
|
90
|
+
<p><em>No approvers required</em></p>
|
|
91
|
+
<% end %>
|
|
92
|
+
|
|
93
|
+
<% if support_user %>
|
|
94
|
+
<%=
|
|
95
|
+
form_with(
|
|
96
|
+
scope: :approval,
|
|
97
|
+
url: plan_my_stuff.issue_approvals_path(issue),
|
|
98
|
+
method: :post,
|
|
99
|
+
local: true,
|
|
100
|
+
) do |form|
|
|
101
|
+
%>
|
|
102
|
+
<%= form.label(:user_ids, 'Add approver IDs (comma-separated)') %>
|
|
103
|
+
<%= form.text_field(:user_ids) %>
|
|
104
|
+
<%= form.submit('Add Approvers') %>
|
|
105
|
+
<% end %>
|
|
106
|
+
<% end %>
|
|
107
|
+
</section>
|
|
108
|
+
<% end %>
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
)
|
|
1
|
+
<%# locals: (issue:) %>
|
|
2
|
+
<%
|
|
3
|
+
persisted = issue.persisted?
|
|
4
|
+
url =
|
|
5
|
+
if persisted
|
|
6
|
+
plan_my_stuff.issue_path(issue)
|
|
7
|
+
else
|
|
8
|
+
plan_my_stuff.issues_path(repo: issue.repo&.full_name)
|
|
9
|
+
end
|
|
10
|
+
%>
|
|
11
|
+
<%= form_with(url: url, method: persisted ? :patch : :post, scope: :issue) do |form| %>
|
|
7
12
|
<div>
|
|
8
13
|
<%= form.label(:title, 'Title') %>
|
|
9
14
|
<%= form.text_field(:title, value: issue.title) %>
|