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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +569 -38
- data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
- data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
- data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
- data/app/jobs/plan_my_stuff/application_job.rb +9 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
- data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
- data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +38 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +138 -5
- data/lib/plan_my_stuff/application_record.rb +121 -0
- data/lib/plan_my_stuff/approval.rb +80 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +14 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
- data/lib/plan_my_stuff/base_project.rb +661 -0
- data/lib/plan_my_stuff/base_project_item.rb +562 -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 +7 -0
- data/lib/plan_my_stuff/comment.rb +171 -50
- data/lib/plan_my_stuff/configuration.rb +210 -10
- data/lib/plan_my_stuff/custom_fields.rb +31 -17
- data/lib/plan_my_stuff/engine.rb +0 -4
- data/lib/plan_my_stuff/errors.rb +49 -0
- data/lib/plan_my_stuff/graphql/queries.rb +392 -0
- data/lib/plan_my_stuff/issue.rb +1476 -175
- data/lib/plan_my_stuff/issue_metadata.rb +122 -0
- data/lib/plan_my_stuff/label.rb +82 -11
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/notifications.rb +142 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +44 -0
- data/lib/plan_my_stuff/pipeline.rb +293 -0
- data/lib/plan_my_stuff/project.rb +30 -693
- data/lib/plan_my_stuff/project_item.rb +3 -417
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +9 -3
- 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 +16 -0
- data/lib/plan_my_stuff/test_helpers.rb +260 -15
- data/lib/plan_my_stuff/testing_project.rb +291 -0
- data/lib/plan_my_stuff/testing_project_item.rb +184 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +8 -3
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
- data/lib/plan_my_stuff.rb +15 -0
- data/lib/tasks/plan_my_stuff.rake +163 -0
- metadata +50 -2
|
@@ -1,422 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
# Class methods:
|
|
8
|
-
# ProjectItem.create!(issue) -- add existing issue
|
|
9
|
-
# ProjectItem.create!(title, draft: true, body: "...") -- create draft item
|
|
10
|
-
# ProjectItem.move_item(item_id:, status:)
|
|
11
|
-
# ProjectItem.assign(item_id:, assignee:)
|
|
12
|
-
#
|
|
13
|
-
# Instance methods:
|
|
14
|
-
# item.move_to!("Done")
|
|
15
|
-
# item.assign!("octocat")
|
|
16
|
-
#
|
|
17
|
-
class ProjectItem < PlanMyStuff::ApplicationRecord
|
|
18
|
-
# @return [String] GitHub node ID (e.g. "PVTI_...")
|
|
19
|
-
attr_reader :id
|
|
20
|
-
# @return [String, nil] node ID of the underlying content (Issue, PR, or DraftIssue)
|
|
21
|
-
attr_reader :content_node_id
|
|
22
|
-
|
|
23
|
-
# @return [String, nil] GitHub item type (e.g. "DRAFT_ISSUE", "ISSUE", "PULL_REQUEST")
|
|
24
|
-
attr_reader :type
|
|
25
|
-
|
|
26
|
-
# @return [String, nil]
|
|
27
|
-
attr_accessor :title
|
|
28
|
-
# @return [Integer, nil]
|
|
29
|
-
attr_accessor :number
|
|
30
|
-
# @return [String, nil]
|
|
31
|
-
attr_accessor :url
|
|
32
|
-
# @return [PlanMyStuff::Repo, nil]
|
|
33
|
-
attr_accessor :repo
|
|
34
|
-
# @return [String, nil]
|
|
35
|
-
attr_accessor :state
|
|
36
|
-
# @return [String, nil]
|
|
37
|
-
attr_accessor :status
|
|
38
|
-
# @return [Hash]
|
|
39
|
-
attr_accessor :field_values
|
|
40
|
-
# @return [PlanMyStuff::Project, nil]
|
|
41
|
-
attr_accessor :project
|
|
42
|
-
# @return [PlanMyStuff::Issue, nil] linked issue (nil for draft items)
|
|
43
|
-
attr_writer :issue
|
|
44
|
-
|
|
45
|
-
class << self
|
|
46
|
-
# Builds a persisted ProjectItem from parsed item data.
|
|
47
|
-
#
|
|
48
|
-
# @param item_hash [Hash] parsed item data (from Project.parse_project_item)
|
|
49
|
-
# @param project [PlanMyStuff::Project] parent project
|
|
50
|
-
#
|
|
51
|
-
# @return [PlanMyStuff::ProjectItem]
|
|
52
|
-
#
|
|
53
|
-
def build(item_hash, project:)
|
|
54
|
-
item = new(
|
|
55
|
-
id: item_hash[:id],
|
|
56
|
-
type: item_hash[:type],
|
|
57
|
-
content_node_id: item_hash[:content_node_id],
|
|
58
|
-
title: item_hash[:title],
|
|
59
|
-
number: item_hash[:number],
|
|
60
|
-
url: item_hash[:url],
|
|
61
|
-
repo: item_hash[:repo],
|
|
62
|
-
state: item_hash[:state],
|
|
63
|
-
status: item_hash[:status],
|
|
64
|
-
field_values: item_hash[:field_values] || {},
|
|
65
|
-
project: project,
|
|
66
|
-
)
|
|
67
|
-
item.__send__(:persisted!)
|
|
68
|
-
item
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Creates a project item by adding an existing issue or creating a draft.
|
|
72
|
-
#
|
|
73
|
-
# @param issue_or_title [PlanMyStuff::Issue, String] Issue instance (non-draft) or title string (draft)
|
|
74
|
-
# @param draft [Boolean] when true, creates a draft item from a title string
|
|
75
|
-
# @param body [String, nil] body for draft items (ignored for non-draft)
|
|
76
|
-
# @param project_number [Integer, nil] defaults to config.default_project_number
|
|
77
|
-
#
|
|
78
|
-
# @return [PlanMyStuff::ProjectItem]
|
|
79
|
-
#
|
|
80
|
-
def create!(issue_or_title, draft: false, body: nil, project_number: nil)
|
|
81
|
-
if draft
|
|
82
|
-
add_draft_item(title: issue_or_title, body: body, project_number: project_number)
|
|
83
|
-
else
|
|
84
|
-
add_item(issue: issue_or_title, project_number: project_number)
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Moves a project item to a new status column.
|
|
89
|
-
#
|
|
90
|
-
# @param item_id [String] project item ID (e.g. "PVTI_...")
|
|
91
|
-
# @param status [String] status name, resolved to option ID internally
|
|
92
|
-
# @param project_number [Integer, nil] defaults to config.default_project_number
|
|
93
|
-
#
|
|
94
|
-
# @return [Hash] the updated item
|
|
95
|
-
#
|
|
96
|
-
def move_item(item_id:, status:, project_number: nil)
|
|
97
|
-
project_number = resolve_default_project_number(project_number)
|
|
98
|
-
project = Project.find(project_number)
|
|
99
|
-
|
|
100
|
-
status_field = project.status_field
|
|
101
|
-
option_id = resolve_status_option_id(status_field, status)
|
|
102
|
-
|
|
103
|
-
PlanMyStuff.client.graphql(
|
|
104
|
-
update_single_select_mutation,
|
|
105
|
-
variables: {
|
|
106
|
-
projectId: project.id,
|
|
107
|
-
itemId: item_id,
|
|
108
|
-
fieldId: status_field[:id],
|
|
109
|
-
optionId: option_id,
|
|
110
|
-
},
|
|
111
|
-
)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Assigns users to a project item.
|
|
115
|
-
# Issues/PRs use REST via Issue.update!, drafts use GraphQL.
|
|
116
|
-
#
|
|
117
|
-
# @param number [Integer, nil] issue number (nil for drafts)
|
|
118
|
-
# @param content_node_id [String] node ID of the underlying content
|
|
119
|
-
# @param assignees [Array<String>] GitHub usernames
|
|
120
|
-
# @param draft [Boolean] whether the item is a draft issue
|
|
121
|
-
# @param repo [Symbol, String, nil] repo key (for issues)
|
|
122
|
-
#
|
|
123
|
-
# @return [void]
|
|
124
|
-
#
|
|
125
|
-
def assign(number:, content_node_id:, assignees:, draft: false, repo: nil)
|
|
126
|
-
if draft
|
|
127
|
-
client = PlanMyStuff.client
|
|
128
|
-
user_ids = assignees.map do |assignee|
|
|
129
|
-
user_data = client.graphql(user_node_id_query, variables: { login: assignee })
|
|
130
|
-
user_id = user_data.dig(:user, :id)
|
|
131
|
-
|
|
132
|
-
raise(APIError, "GitHub user not found: #{assignee}") if user_id.nil?
|
|
133
|
-
|
|
134
|
-
user_id
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
client.graphql(
|
|
138
|
-
assign_draft_mutation,
|
|
139
|
-
variables: { draftIssueId: content_node_id, assigneeIds: user_ids },
|
|
140
|
-
)
|
|
141
|
-
else
|
|
142
|
-
Issue.update!(number: number, repo: repo, assignees: assignees)
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
private
|
|
147
|
-
|
|
148
|
-
# Adds a GitHub issue to a project board.
|
|
149
|
-
#
|
|
150
|
-
# @param issue [PlanMyStuff::Issue]
|
|
151
|
-
# @param project_number [Integer, nil]
|
|
152
|
-
#
|
|
153
|
-
# @return [PlanMyStuff::ProjectItem]
|
|
154
|
-
#
|
|
155
|
-
def add_item(issue:, project_number:)
|
|
156
|
-
project_number = resolve_default_project_number(project_number)
|
|
157
|
-
client = PlanMyStuff.client
|
|
158
|
-
org = PlanMyStuff.configuration.organization
|
|
159
|
-
|
|
160
|
-
github_issue = client.rest(:issue, issue.repo, issue.number)
|
|
161
|
-
node_id = github_issue.respond_to?(:node_id) ? github_issue.node_id : github_issue[:node_id]
|
|
162
|
-
|
|
163
|
-
project_id = resolve_project_id(org, project_number)
|
|
164
|
-
|
|
165
|
-
data = client.graphql(
|
|
166
|
-
add_item_mutation,
|
|
167
|
-
variables: { projectId: project_id, contentId: node_id },
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
item_data = data.dig(:addProjectV2ItemById, :item) || {}
|
|
171
|
-
project = Project.find(project_number)
|
|
172
|
-
|
|
173
|
-
item = build(
|
|
174
|
-
{
|
|
175
|
-
id: item_data[:id],
|
|
176
|
-
content_node_id: node_id,
|
|
177
|
-
title: issue.title,
|
|
178
|
-
number: issue.number,
|
|
179
|
-
url: nil,
|
|
180
|
-
state: issue.state,
|
|
181
|
-
status: nil,
|
|
182
|
-
field_values: {},
|
|
183
|
-
},
|
|
184
|
-
project: project,
|
|
185
|
-
)
|
|
186
|
-
item.issue = issue
|
|
187
|
-
item
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# Adds a draft issue to a project board.
|
|
191
|
-
#
|
|
192
|
-
# @param title [String]
|
|
193
|
-
# @param body [String, nil]
|
|
194
|
-
# @param project_number [Integer, nil]
|
|
195
|
-
#
|
|
196
|
-
# @return [PlanMyStuff::ProjectItem]
|
|
197
|
-
#
|
|
198
|
-
def add_draft_item(title:, body:, project_number:)
|
|
199
|
-
project_number = resolve_default_project_number(project_number)
|
|
200
|
-
org = PlanMyStuff.configuration.organization
|
|
201
|
-
project_id = resolve_project_id(org, project_number)
|
|
202
|
-
|
|
203
|
-
variables = { projectId: project_id, title: title }
|
|
204
|
-
variables[:body] = body if body.present?
|
|
205
|
-
|
|
206
|
-
data = PlanMyStuff.client.graphql(
|
|
207
|
-
add_draft_item_mutation,
|
|
208
|
-
variables: variables,
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
item_data = data.dig(:addProjectV2DraftIssue, :projectItem) || {}
|
|
212
|
-
project = Project.find(project_number)
|
|
213
|
-
|
|
214
|
-
build(
|
|
215
|
-
{
|
|
216
|
-
id: item_data[:id],
|
|
217
|
-
content_node_id: item_data.dig(:content, :id),
|
|
218
|
-
type: 'DRAFT_ISSUE',
|
|
219
|
-
title: title,
|
|
220
|
-
number: nil,
|
|
221
|
-
url: nil,
|
|
222
|
-
state: nil,
|
|
223
|
-
status: nil,
|
|
224
|
-
field_values: {},
|
|
225
|
-
},
|
|
226
|
-
project: project,
|
|
227
|
-
)
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
# @see PlanMyStuff::Project.resolve_default_project_number
|
|
231
|
-
def resolve_default_project_number(project_number)
|
|
232
|
-
PlanMyStuff::Project.resolve_default_project_number(project_number)
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# @return [String]
|
|
236
|
-
def add_item_mutation
|
|
237
|
-
<<~GRAPHQL
|
|
238
|
-
mutation($projectId: ID!, $contentId: ID!) {
|
|
239
|
-
addProjectV2ItemById(input: {
|
|
240
|
-
projectId: $projectId,
|
|
241
|
-
contentId: $contentId
|
|
242
|
-
}) {
|
|
243
|
-
item {
|
|
244
|
-
id
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
GRAPHQL
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
# @return [String]
|
|
252
|
-
def add_draft_item_mutation
|
|
253
|
-
<<~GRAPHQL
|
|
254
|
-
mutation($projectId: ID!, $title: String!, $body: String) {
|
|
255
|
-
addProjectV2DraftIssue(input: {
|
|
256
|
-
projectId: $projectId,
|
|
257
|
-
title: $title,
|
|
258
|
-
body: $body
|
|
259
|
-
}) {
|
|
260
|
-
projectItem {
|
|
261
|
-
id
|
|
262
|
-
content { ... on DraftIssue { id } }
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
GRAPHQL
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
# @return [String]
|
|
270
|
-
def update_single_select_mutation
|
|
271
|
-
<<~GRAPHQL
|
|
272
|
-
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
273
|
-
updateProjectV2ItemFieldValue(input: {
|
|
274
|
-
projectId: $projectId,
|
|
275
|
-
itemId: $itemId,
|
|
276
|
-
fieldId: $fieldId,
|
|
277
|
-
value: { singleSelectOptionId: $optionId }
|
|
278
|
-
}) {
|
|
279
|
-
projectV2Item {
|
|
280
|
-
id
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
GRAPHQL
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
# @return [String]
|
|
288
|
-
def assign_draft_mutation
|
|
289
|
-
<<~GRAPHQL
|
|
290
|
-
mutation($draftIssueId: ID!, $assigneeIds: [ID!]) {
|
|
291
|
-
updateProjectV2DraftIssue(input: {
|
|
292
|
-
draftIssueId: $draftIssueId,
|
|
293
|
-
assigneeIds: $assigneeIds
|
|
294
|
-
}) {
|
|
295
|
-
draftIssue { id }
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
GRAPHQL
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
# @return [String]
|
|
302
|
-
def user_node_id_query
|
|
303
|
-
<<~GRAPHQL
|
|
304
|
-
query($login: String!) {
|
|
305
|
-
user(login: $login) {
|
|
306
|
-
id
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
GRAPHQL
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
# @return [String]
|
|
313
|
-
def project_id_query
|
|
314
|
-
<<~GRAPHQL
|
|
315
|
-
query($org: String!, $number: Int!) {
|
|
316
|
-
organization(login: $org) {
|
|
317
|
-
projectV2(number: $number) {
|
|
318
|
-
id
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
GRAPHQL
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
# Resolves a status name to its option ID.
|
|
326
|
-
#
|
|
327
|
-
# @param status_field [Hash]
|
|
328
|
-
# @param status_name [String]
|
|
329
|
-
#
|
|
330
|
-
# @return [String]
|
|
331
|
-
#
|
|
332
|
-
def resolve_status_option_id(status_field, status_name)
|
|
333
|
-
option = status_field[:options].find { |o| o[:name] == status_name }
|
|
334
|
-
|
|
335
|
-
unless option
|
|
336
|
-
available = status_field[:options].pluck(:name).join(', ')
|
|
337
|
-
raise(ArgumentError, "Unknown status '#{status_name}'. Available: #{available}")
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
option[:id]
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
# Resolves a project number to its node ID.
|
|
344
|
-
#
|
|
345
|
-
# @param org [String]
|
|
346
|
-
# @param project_number [Integer]
|
|
347
|
-
#
|
|
348
|
-
# @return [String]
|
|
349
|
-
#
|
|
350
|
-
def resolve_project_id(org, project_number)
|
|
351
|
-
data = PlanMyStuff.client.graphql(
|
|
352
|
-
project_id_query,
|
|
353
|
-
variables: { org: org, number: project_number },
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
data.dig(:organization, :projectV2, :id)
|
|
357
|
-
end
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
# @see super
|
|
361
|
-
def initialize(**attrs)
|
|
362
|
-
@id = attrs.delete(:id)
|
|
363
|
-
@type = attrs.delete(:type)
|
|
364
|
-
@content_node_id = attrs.delete(:content_node_id)
|
|
365
|
-
super
|
|
366
|
-
@field_values ||= {}
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# Moves this item to a new status column on its parent project.
|
|
370
|
-
#
|
|
371
|
-
# @param status [String] status name (e.g. "In Progress", "Done")
|
|
372
|
-
#
|
|
373
|
-
# @return [Hash] mutation result
|
|
374
|
-
#
|
|
375
|
-
def move_to!(status)
|
|
376
|
-
self.class.move_item(
|
|
377
|
-
project_number: project.number,
|
|
378
|
-
item_id: id,
|
|
379
|
-
status: status,
|
|
380
|
-
)
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
# Assigns users to this item on its parent project.
|
|
384
|
-
#
|
|
385
|
-
# @param assignees [String, Array<String>] GitHub username(s)
|
|
386
|
-
#
|
|
387
|
-
# @return [void]
|
|
388
|
-
#
|
|
389
|
-
def assign!(assignees)
|
|
390
|
-
self.class.assign(
|
|
391
|
-
number: number,
|
|
392
|
-
content_node_id: content_node_id,
|
|
393
|
-
assignees: Array.wrap(assignees),
|
|
394
|
-
draft: draft?,
|
|
395
|
-
repo: repo,
|
|
396
|
-
)
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
# @return [Boolean]
|
|
400
|
-
def draft?
|
|
401
|
-
type == 'DRAFT_ISSUE'
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
# @return [PlanMyStuff::Issue, nil]
|
|
405
|
-
def issue
|
|
406
|
-
return @issue if defined?(@issue)
|
|
407
|
-
return if draft?
|
|
408
|
-
|
|
409
|
-
@issue = PMS::Issue.find(number, repo: repo)
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
private
|
|
413
|
-
|
|
414
|
-
# Marks this record as persisted.
|
|
415
|
-
#
|
|
416
|
-
# @return [void]
|
|
417
|
-
#
|
|
418
|
-
def persisted!
|
|
419
|
-
@persisted = true
|
|
420
|
-
end
|
|
4
|
+
# Concrete project item subclass for standard (non-testing) GitHub Projects V2
|
|
5
|
+
# projects. All generic machinery lives in BaseProjectItem.
|
|
6
|
+
class ProjectItem < PlanMyStuff::BaseProjectItem
|
|
421
7
|
end
|
|
422
8
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Metadata stored on the body of a +ProjectItem+ -- primarily used for
|
|
5
|
+
# draft items, which have their own body field distinct from any linked
|
|
6
|
+
# issue. Regular (non-draft) items rely on their linked issue's
|
|
7
|
+
# +IssueMetadata+ instead, to avoid double-bookkeeping.
|
|
8
|
+
#
|
|
9
|
+
# Lays the groundwork for the Phase 13 testing-tracking workflow
|
|
10
|
+
# (T-049), which will assign non-GitHub users (e.g. internal QA
|
|
11
|
+
# accounts) to draft items via the +pms_assignee+ field.
|
|
12
|
+
#
|
|
13
|
+
class ProjectItemMetadata < BaseMetadata
|
|
14
|
+
# @return [Integer, nil] consuming app user id of the PMS-side assignee
|
|
15
|
+
attr_accessor :pms_assignee
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Builds a ProjectItemMetadata from a parsed hash (e.g. from MetadataParser)
|
|
19
|
+
#
|
|
20
|
+
# @param hash [Hash]
|
|
21
|
+
#
|
|
22
|
+
# @return [PlanMyStuff::ProjectItemMetadata]
|
|
23
|
+
#
|
|
24
|
+
def from_hash(hash)
|
|
25
|
+
metadata = new
|
|
26
|
+
apply_common_from_hash(metadata, hash, {})
|
|
27
|
+
metadata.pms_assignee = hash[:pms_assignee]
|
|
28
|
+
metadata
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Builds a new ProjectItemMetadata, auto-filling gem defaults.
|
|
32
|
+
#
|
|
33
|
+
# @param user [Object, Integer] user object or user_id
|
|
34
|
+
# @param visibility [String] "public" or "internal"
|
|
35
|
+
#
|
|
36
|
+
# @return [PlanMyStuff::ProjectItemMetadata]
|
|
37
|
+
#
|
|
38
|
+
def build(user:, visibility: 'internal')
|
|
39
|
+
metadata = new
|
|
40
|
+
apply_common_build(
|
|
41
|
+
metadata,
|
|
42
|
+
user: user,
|
|
43
|
+
visibility: visibility,
|
|
44
|
+
)
|
|
45
|
+
metadata.pms_assignee = nil
|
|
46
|
+
metadata
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Hash]
|
|
51
|
+
def to_h
|
|
52
|
+
super.merge(pms_assignee: pms_assignee)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
|
|
4
|
+
# Metadata for regular (non-testing) projects.
|
|
5
|
+
class ProjectMetadata < BaseProjectMetadata
|
|
5
6
|
class << self
|
|
6
7
|
# Builds a ProjectMetadata from a parsed hash (e.g. from MetadataParser)
|
|
7
8
|
#
|
|
@@ -12,7 +13,7 @@ module PlanMyStuff
|
|
|
12
13
|
def from_hash(hash)
|
|
13
14
|
metadata = new
|
|
14
15
|
apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:project))
|
|
15
|
-
|
|
16
|
+
metadata.kind = hash.fetch(:kind, 'project')
|
|
16
17
|
metadata
|
|
17
18
|
end
|
|
18
19
|
|
|
@@ -33,9 +34,14 @@ module PlanMyStuff
|
|
|
33
34
|
custom_fields_data: custom_fields,
|
|
34
35
|
custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:project),
|
|
35
36
|
)
|
|
36
|
-
|
|
37
|
+
metadata.kind = 'project'
|
|
37
38
|
metadata
|
|
38
39
|
end
|
|
39
40
|
end
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
super
|
|
44
|
+
@kind = 'project'
|
|
45
|
+
end
|
|
40
46
|
end
|
|
41
47
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module Reminders
|
|
5
|
+
# Auto-closes a waiting issue that has exceeded the configured
|
|
6
|
+
# +inactivity_close_days+ ceiling. Clears waiting state and emits a
|
|
7
|
+
# dedicated +plan_my_stuff.issue.closed_inactive+ event.
|
|
8
|
+
#
|
|
9
|
+
# Goes through +Issue#update!+ with +skip_notification: true+ so the
|
|
10
|
+
# regular +issue.closed+ event is suppressed in favor of the
|
|
11
|
+
# dedicated +issue.closed_inactive+. +Issue#persist_update!+'s
|
|
12
|
+
# +clear_waiting_state_on_close!+ hook handles stripping waiting
|
|
13
|
+
# labels and clearing waiting timestamps in the single save write.
|
|
14
|
+
class Closer
|
|
15
|
+
# Returns +true+ when the issue has waited longer than
|
|
16
|
+
# +inactivity_close_days+ relative to +now+.
|
|
17
|
+
#
|
|
18
|
+
# @param issue [PlanMyStuff::Issue]
|
|
19
|
+
# @param now [Time]
|
|
20
|
+
#
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
#
|
|
23
|
+
def self.should_close?(issue, now: Time.now.utc)
|
|
24
|
+
start = issue.metadata.waiting_on_user_at || issue.metadata.waiting_on_approval_at
|
|
25
|
+
return false if start.nil?
|
|
26
|
+
|
|
27
|
+
((now.utc - start) / 1.day) >= PlanMyStuff.configuration.inactivity_close_days
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param issue [PlanMyStuff::Issue] candidate issue from the sweep
|
|
31
|
+
# @param now [Time] clock reference
|
|
32
|
+
#
|
|
33
|
+
def initialize(issue, now: Time.now.utc)
|
|
34
|
+
@issue = issue
|
|
35
|
+
@now = now.utc
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Closes the issue, tags it with the configured
|
|
39
|
+
# +user_inactive_label+, then emits
|
|
40
|
+
# +plan_my_stuff.issue.closed_inactive+.
|
|
41
|
+
#
|
|
42
|
+
# @return [void]
|
|
43
|
+
#
|
|
44
|
+
def call
|
|
45
|
+
@issue.update!(
|
|
46
|
+
state: :closed,
|
|
47
|
+
metadata: { closed_by_inactivity: true },
|
|
48
|
+
skip_notification: true,
|
|
49
|
+
)
|
|
50
|
+
add_user_inactive_label
|
|
51
|
+
PlanMyStuff::Notifications.instrument(
|
|
52
|
+
'issue.closed_inactive',
|
|
53
|
+
@issue,
|
|
54
|
+
reason: :inactivity,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# @return [void]
|
|
61
|
+
def add_user_inactive_label
|
|
62
|
+
label = PlanMyStuff.configuration.user_inactive_label
|
|
63
|
+
return if @issue.labels.include?(label)
|
|
64
|
+
|
|
65
|
+
PlanMyStuff::Label.ensure!(repo: @issue.repo, name: label)
|
|
66
|
+
PlanMyStuff::Label.add(issue: @issue, labels: [label])
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module Reminders
|
|
5
|
+
# Emits +plan_my_stuff.issue.reminder_due+ for a single waiting issue
|
|
6
|
+
# and advances its +next_reminder_at+ to the next milestone in the
|
|
7
|
+
# effective +reminder_days+ schedule (or +nil+ when the last milestone
|
|
8
|
+
# has passed).
|
|
9
|
+
class Fire
|
|
10
|
+
# Returns +true+ when the issue has a +next_reminder_at+ in the
|
|
11
|
+
# past relative to +now+.
|
|
12
|
+
#
|
|
13
|
+
# @param issue [PlanMyStuff::Issue]
|
|
14
|
+
# @param now [Time]
|
|
15
|
+
#
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
#
|
|
18
|
+
def self.ready?(issue, now: Time.now.utc)
|
|
19
|
+
due = issue.metadata.next_reminder_at
|
|
20
|
+
due.present? && due <= now
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param issue [PlanMyStuff::Issue] candidate issue from the sweep
|
|
24
|
+
# @param now [Time] clock reference (defaults to +Time.now.utc+)
|
|
25
|
+
#
|
|
26
|
+
def initialize(issue, now: Time.now.utc)
|
|
27
|
+
@issue = issue
|
|
28
|
+
@now = now.utc
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Emits the reminder event and advances the schedule.
|
|
32
|
+
#
|
|
33
|
+
# @return [void]
|
|
34
|
+
#
|
|
35
|
+
def call
|
|
36
|
+
payload = build_payload
|
|
37
|
+
PlanMyStuff::Notifications.instrument('issue.reminder_due', @issue, **payload)
|
|
38
|
+
|
|
39
|
+
@issue.update!(
|
|
40
|
+
metadata: { next_reminder_at: next_reminder_at_value },
|
|
41
|
+
skip_notification: true,
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# @return [Hash]
|
|
48
|
+
def build_payload
|
|
49
|
+
payload = {
|
|
50
|
+
waiting_kind: waiting_kind,
|
|
51
|
+
days_waiting: days_waiting,
|
|
52
|
+
reminder_day: reminder_day,
|
|
53
|
+
last_activity_at: last_activity_at,
|
|
54
|
+
}
|
|
55
|
+
payload[:pending_approvers] = pending_approvers if waiting_kind == :approval
|
|
56
|
+
payload
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Symbol] +:user+ or +:approval+
|
|
60
|
+
def waiting_kind
|
|
61
|
+
@issue.metadata.waiting_on_user_at.present? ? :user : :approval
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Time] the timestamp waiting started for the active kind
|
|
65
|
+
def starting_clock
|
|
66
|
+
@issue.metadata.waiting_on_user_at || @issue.metadata.waiting_on_approval_at
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Integer]
|
|
70
|
+
def days_waiting
|
|
71
|
+
((@now - starting_clock) / 1.day).to_i
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Most recent milestone passed, used as the +reminder_day+ label in
|
|
75
|
+
# the event payload.
|
|
76
|
+
#
|
|
77
|
+
# @return [Integer, nil]
|
|
78
|
+
#
|
|
79
|
+
def reminder_day
|
|
80
|
+
effective_reminder_days.select { |d| d <= days_waiting }.max
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Informational: last comment timestamp (falling back to the
|
|
84
|
+
# issue's GitHub +updated_at+ when there are no comments). Uses
|
|
85
|
+
# +updated_at+ on Comment since that's the only timestamp PMS
|
|
86
|
+
# exposes on comment records.
|
|
87
|
+
#
|
|
88
|
+
# @return [Time, nil]
|
|
89
|
+
#
|
|
90
|
+
def last_activity_at
|
|
91
|
+
last_comment = @issue.comments.max_by { |c| c.updated_at || Time.at(0).utc }
|
|
92
|
+
last_comment&.updated_at || @issue.updated_at
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Resolves pending-approval user IDs via +UserResolver+. IDs that
|
|
96
|
+
# fail to resolve (e.g. deleted users) are dropped.
|
|
97
|
+
#
|
|
98
|
+
# @return [Array<Object>]
|
|
99
|
+
#
|
|
100
|
+
def pending_approvers
|
|
101
|
+
@issue.pending_approvals.filter_map do |approval|
|
|
102
|
+
PlanMyStuff::UserResolver.resolve(approval.user_id)
|
|
103
|
+
rescue
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Array<Integer>]
|
|
109
|
+
def effective_reminder_days
|
|
110
|
+
@issue.metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Time of the next milestone past +now+, or +nil+ when the last
|
|
114
|
+
# milestone has passed. Returned as a native +Time+ (not ISO
|
|
115
|
+
# string) so +IssueMetadata#to_h+'s +format_time+ serializes it
|
|
116
|
+
# cleanly.
|
|
117
|
+
#
|
|
118
|
+
# @return [Time, nil]
|
|
119
|
+
#
|
|
120
|
+
def next_reminder_at_value
|
|
121
|
+
start = starting_clock
|
|
122
|
+
remaining = effective_reminder_days.select { |d| start + d.days > @now }
|
|
123
|
+
return if remaining.empty?
|
|
124
|
+
|
|
125
|
+
(start + remaining.first.days).utc
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|