plan_my_stuff 0.1.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 +7 -0
- data/LICENSE +28 -0
- data/README.md +284 -0
- data/app/controllers/plan_my_stuff/application_controller.rb +76 -0
- data/app/controllers/plan_my_stuff/comments_controller.rb +82 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +145 -0
- data/app/controllers/plan_my_stuff/labels_controller.rb +30 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +93 -0
- data/app/controllers/plan_my_stuff/projects_controller.rb +17 -0
- data/app/views/plan_my_stuff/comments/edit.html.erb +16 -0
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +32 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +12 -0
- data/app/views/plan_my_stuff/issues/index.html.erb +37 -0
- data/app/views/plan_my_stuff/issues/new.html.erb +7 -0
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +41 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +23 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +32 -0
- data/app/views/plan_my_stuff/issues/show.html.erb +58 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +13 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +101 -0
- data/config/routes.rb +25 -0
- data/lib/generators/plan_my_stuff/install/install_generator.rb +38 -0
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +106 -0
- data/lib/generators/plan_my_stuff/views/views_generator.rb +22 -0
- data/lib/plan_my_stuff/application_record.rb +39 -0
- data/lib/plan_my_stuff/base_metadata.rb +136 -0
- data/lib/plan_my_stuff/client.rb +143 -0
- data/lib/plan_my_stuff/comment.rb +360 -0
- data/lib/plan_my_stuff/comment_metadata.rb +56 -0
- data/lib/plan_my_stuff/configuration.rb +139 -0
- data/lib/plan_my_stuff/custom_fields.rb +65 -0
- data/lib/plan_my_stuff/engine.rb +11 -0
- data/lib/plan_my_stuff/errors.rb +87 -0
- data/lib/plan_my_stuff/issue.rb +486 -0
- data/lib/plan_my_stuff/issue_metadata.rb +111 -0
- data/lib/plan_my_stuff/label.rb +59 -0
- data/lib/plan_my_stuff/markdown.rb +83 -0
- data/lib/plan_my_stuff/metadata_parser.rb +53 -0
- data/lib/plan_my_stuff/project.rb +504 -0
- data/lib/plan_my_stuff/project_item.rb +414 -0
- data/lib/plan_my_stuff/test_helpers.rb +501 -0
- data/lib/plan_my_stuff/user_resolver.rb +61 -0
- data/lib/plan_my_stuff/verifier.rb +102 -0
- data/lib/plan_my_stuff/version.rb +19 -0
- data/lib/plan_my_stuff.rb +69 -0
- data/lib/tasks/plan_my_stuff.rake +23 -0
- metadata +126 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Wraps a single item within a GitHub Projects V2 project.
|
|
5
|
+
# Stores a reference to its parent Project for delegation.
|
|
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 [String, nil]
|
|
33
|
+
attr_accessor :state
|
|
34
|
+
# @return [String, nil]
|
|
35
|
+
attr_accessor :status
|
|
36
|
+
# @return [Hash]
|
|
37
|
+
attr_accessor :field_values
|
|
38
|
+
# @return [PlanMyStuff::Project, nil]
|
|
39
|
+
attr_accessor :project
|
|
40
|
+
# @return [PlanMyStuff::Issue, nil] linked issue (nil for draft items)
|
|
41
|
+
attr_accessor :issue
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# Builds a persisted ProjectItem from parsed item data.
|
|
45
|
+
#
|
|
46
|
+
# @param item_hash [Hash] parsed item data (from Project.parse_project_item)
|
|
47
|
+
# @param project [PlanMyStuff::Project] parent project
|
|
48
|
+
#
|
|
49
|
+
# @return [PlanMyStuff::ProjectItem]
|
|
50
|
+
#
|
|
51
|
+
def build(item_hash, project:)
|
|
52
|
+
item = new(
|
|
53
|
+
id: item_hash[:id],
|
|
54
|
+
type: item_hash[:type],
|
|
55
|
+
content_node_id: item_hash[:content_node_id],
|
|
56
|
+
title: item_hash[:title],
|
|
57
|
+
number: item_hash[:number],
|
|
58
|
+
url: item_hash[:url],
|
|
59
|
+
state: item_hash[:state],
|
|
60
|
+
status: item_hash[:status],
|
|
61
|
+
field_values: item_hash[:field_values] || {},
|
|
62
|
+
project: project,
|
|
63
|
+
)
|
|
64
|
+
item.__send__(:persisted!)
|
|
65
|
+
item
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Creates a project item by adding an existing issue or creating a draft.
|
|
69
|
+
#
|
|
70
|
+
# @param issue_or_title [PlanMyStuff::Issue, String] Issue instance (non-draft) or title string (draft)
|
|
71
|
+
# @param draft [Boolean] when true, creates a draft item from a title string
|
|
72
|
+
# @param body [String, nil] body for draft items (ignored for non-draft)
|
|
73
|
+
# @param project_number [Integer, nil] defaults to config.default_project_number
|
|
74
|
+
#
|
|
75
|
+
# @return [PlanMyStuff::ProjectItem]
|
|
76
|
+
#
|
|
77
|
+
def create!(issue_or_title, draft: false, body: nil, project_number: nil)
|
|
78
|
+
if draft
|
|
79
|
+
add_draft_item(title: issue_or_title, body: body, project_number: project_number)
|
|
80
|
+
else
|
|
81
|
+
add_item(issue: issue_or_title, project_number: project_number)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Moves a project item to a new status column.
|
|
86
|
+
#
|
|
87
|
+
# @param item_id [String] project item ID (e.g. "PVTI_...")
|
|
88
|
+
# @param status [String] status name, resolved to option ID internally
|
|
89
|
+
# @param project_number [Integer, nil] defaults to config.default_project_number
|
|
90
|
+
#
|
|
91
|
+
# @return [Hash] the updated item
|
|
92
|
+
#
|
|
93
|
+
def move_item(item_id:, status:, project_number: nil)
|
|
94
|
+
project_number = resolve_default_project_number(project_number)
|
|
95
|
+
project = Project.find(project_number)
|
|
96
|
+
|
|
97
|
+
status_field = project.status_field
|
|
98
|
+
option_id = resolve_status_option_id(status_field, status)
|
|
99
|
+
|
|
100
|
+
PlanMyStuff.client.graphql(
|
|
101
|
+
update_single_select_mutation,
|
|
102
|
+
variables: {
|
|
103
|
+
projectId: project.id,
|
|
104
|
+
itemId: item_id,
|
|
105
|
+
fieldId: status_field[:id],
|
|
106
|
+
optionId: option_id,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Assigns users to a project item.
|
|
112
|
+
# Issues/PRs use REST via Issue.update!, drafts use GraphQL.
|
|
113
|
+
#
|
|
114
|
+
# @param number [Integer, nil] issue number (nil for drafts)
|
|
115
|
+
# @param content_node_id [String] node ID of the underlying content
|
|
116
|
+
# @param assignees [Array<String>] GitHub usernames
|
|
117
|
+
# @param draft [Boolean] whether the item is a draft issue
|
|
118
|
+
# @param repo [Symbol, String, nil] repo key (for issues)
|
|
119
|
+
#
|
|
120
|
+
# @return [void]
|
|
121
|
+
#
|
|
122
|
+
def assign(number:, content_node_id:, assignees:, draft: false, repo: nil)
|
|
123
|
+
if draft
|
|
124
|
+
client = PlanMyStuff.client
|
|
125
|
+
user_ids = assignees.map do |assignee|
|
|
126
|
+
user_data = client.graphql(user_node_id_query, variables: { login: assignee })
|
|
127
|
+
user_id = user_data.dig(:user, :id)
|
|
128
|
+
|
|
129
|
+
raise(APIError, "GitHub user not found: #{assignee}") if user_id.nil?
|
|
130
|
+
|
|
131
|
+
user_id
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
client.graphql(
|
|
135
|
+
assign_draft_mutation,
|
|
136
|
+
variables: { draftIssueId: content_node_id, assigneeIds: user_ids },
|
|
137
|
+
)
|
|
138
|
+
else
|
|
139
|
+
Issue.update!(number: number, repo: repo, assignees: assignees)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Adds a GitHub issue to a project board.
|
|
146
|
+
#
|
|
147
|
+
# @param issue [PlanMyStuff::Issue]
|
|
148
|
+
# @param project_number [Integer, nil]
|
|
149
|
+
#
|
|
150
|
+
# @return [PlanMyStuff::ProjectItem]
|
|
151
|
+
#
|
|
152
|
+
def add_item(issue:, project_number:)
|
|
153
|
+
project_number = resolve_default_project_number(project_number)
|
|
154
|
+
client = PlanMyStuff.client
|
|
155
|
+
org = PlanMyStuff.configuration.organization
|
|
156
|
+
|
|
157
|
+
github_issue = client.rest(:issue, issue.repo, issue.number)
|
|
158
|
+
node_id = github_issue.respond_to?(:node_id) ? github_issue.node_id : github_issue[:node_id]
|
|
159
|
+
|
|
160
|
+
project_id = resolve_project_id(org, project_number)
|
|
161
|
+
|
|
162
|
+
data = client.graphql(
|
|
163
|
+
add_item_mutation,
|
|
164
|
+
variables: { projectId: project_id, contentId: node_id },
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
item_data = data.dig(:addProjectV2ItemById, :item) || {}
|
|
168
|
+
project = Project.find(project_number)
|
|
169
|
+
|
|
170
|
+
item = build(
|
|
171
|
+
{
|
|
172
|
+
id: item_data[:id],
|
|
173
|
+
title: issue.title,
|
|
174
|
+
number: issue.number,
|
|
175
|
+
url: nil,
|
|
176
|
+
state: issue.state,
|
|
177
|
+
status: nil,
|
|
178
|
+
field_values: {},
|
|
179
|
+
},
|
|
180
|
+
project: project,
|
|
181
|
+
)
|
|
182
|
+
item.issue = issue
|
|
183
|
+
item
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Adds a draft issue to a project board.
|
|
187
|
+
#
|
|
188
|
+
# @param title [String]
|
|
189
|
+
# @param body [String, nil]
|
|
190
|
+
# @param project_number [Integer, nil]
|
|
191
|
+
#
|
|
192
|
+
# @return [PlanMyStuff::ProjectItem]
|
|
193
|
+
#
|
|
194
|
+
def add_draft_item(title:, body:, project_number:)
|
|
195
|
+
project_number = resolve_default_project_number(project_number)
|
|
196
|
+
org = PlanMyStuff.configuration.organization
|
|
197
|
+
project_id = resolve_project_id(org, project_number)
|
|
198
|
+
|
|
199
|
+
variables = { projectId: project_id, title: title }
|
|
200
|
+
variables[:body] = body if body.present?
|
|
201
|
+
|
|
202
|
+
data = PlanMyStuff.client.graphql(
|
|
203
|
+
add_draft_item_mutation,
|
|
204
|
+
variables: variables,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
item_data = data.dig(:addProjectV2DraftIssue, :projectItem) || {}
|
|
208
|
+
project = Project.find(project_number)
|
|
209
|
+
|
|
210
|
+
build(
|
|
211
|
+
{
|
|
212
|
+
id: item_data[:id],
|
|
213
|
+
title: title,
|
|
214
|
+
number: nil,
|
|
215
|
+
url: nil,
|
|
216
|
+
state: nil,
|
|
217
|
+
status: nil,
|
|
218
|
+
field_values: {},
|
|
219
|
+
},
|
|
220
|
+
project: project,
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Resolves a project number, falling back to config.default_project_number.
|
|
225
|
+
#
|
|
226
|
+
# @param project_number [Integer, nil]
|
|
227
|
+
#
|
|
228
|
+
# @return [Integer]
|
|
229
|
+
#
|
|
230
|
+
def resolve_default_project_number(project_number)
|
|
231
|
+
return project_number if project_number.present?
|
|
232
|
+
|
|
233
|
+
PlanMyStuff.configuration.default_project_number ||
|
|
234
|
+
raise(ArgumentError, 'project_number is required when config.default_project_number is not set')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @return [String]
|
|
238
|
+
def add_item_mutation
|
|
239
|
+
<<~GRAPHQL
|
|
240
|
+
mutation($projectId: ID!, $contentId: ID!) {
|
|
241
|
+
addProjectV2ItemById(input: {
|
|
242
|
+
projectId: $projectId,
|
|
243
|
+
contentId: $contentId
|
|
244
|
+
}) {
|
|
245
|
+
item {
|
|
246
|
+
id
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
GRAPHQL
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# @return [String]
|
|
254
|
+
def add_draft_item_mutation
|
|
255
|
+
<<~GRAPHQL
|
|
256
|
+
mutation($projectId: ID!, $title: String!, $body: String) {
|
|
257
|
+
addProjectV2DraftIssue(input: {
|
|
258
|
+
projectId: $projectId,
|
|
259
|
+
title: $title,
|
|
260
|
+
body: $body
|
|
261
|
+
}) {
|
|
262
|
+
projectItem {
|
|
263
|
+
id
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
GRAPHQL
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @return [String]
|
|
271
|
+
def update_single_select_mutation
|
|
272
|
+
<<~GRAPHQL
|
|
273
|
+
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
274
|
+
updateProjectV2ItemFieldValue(input: {
|
|
275
|
+
projectId: $projectId,
|
|
276
|
+
itemId: $itemId,
|
|
277
|
+
fieldId: $fieldId,
|
|
278
|
+
value: { singleSelectOptionId: $optionId }
|
|
279
|
+
}) {
|
|
280
|
+
projectV2Item {
|
|
281
|
+
id
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
GRAPHQL
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# @return [String]
|
|
289
|
+
def assign_draft_mutation
|
|
290
|
+
<<~GRAPHQL
|
|
291
|
+
mutation($draftIssueId: ID!, $assigneeIds: [ID!]) {
|
|
292
|
+
updateProjectV2DraftIssue(input: {
|
|
293
|
+
draftIssueId: $draftIssueId,
|
|
294
|
+
assigneeIds: $assigneeIds
|
|
295
|
+
}) {
|
|
296
|
+
draftIssue { id }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
GRAPHQL
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# @return [String]
|
|
303
|
+
def user_node_id_query
|
|
304
|
+
<<~GRAPHQL
|
|
305
|
+
query($login: String!) {
|
|
306
|
+
user(login: $login) {
|
|
307
|
+
id
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
GRAPHQL
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# @return [String]
|
|
314
|
+
def project_id_query
|
|
315
|
+
<<~GRAPHQL
|
|
316
|
+
query($org: String!, $number: Int!) {
|
|
317
|
+
organization(login: $org) {
|
|
318
|
+
projectV2(number: $number) {
|
|
319
|
+
id
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
GRAPHQL
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Resolves a status name to its option ID.
|
|
327
|
+
#
|
|
328
|
+
# @param status_field [Hash]
|
|
329
|
+
# @param status_name [String]
|
|
330
|
+
#
|
|
331
|
+
# @return [String]
|
|
332
|
+
#
|
|
333
|
+
def resolve_status_option_id(status_field, status_name)
|
|
334
|
+
option = status_field[:options].find { |o| o[:name] == status_name }
|
|
335
|
+
|
|
336
|
+
unless option
|
|
337
|
+
available = status_field[:options].pluck(:name).join(', ')
|
|
338
|
+
raise(ArgumentError, "Unknown status '#{status_name}'. Available: #{available}")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
option[:id]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Resolves a project number to its node ID.
|
|
345
|
+
#
|
|
346
|
+
# @param org [String]
|
|
347
|
+
# @param project_number [Integer]
|
|
348
|
+
#
|
|
349
|
+
# @return [String]
|
|
350
|
+
#
|
|
351
|
+
def resolve_project_id(org, project_number)
|
|
352
|
+
data = PlanMyStuff.client.graphql(
|
|
353
|
+
project_id_query,
|
|
354
|
+
variables: { org: org, number: project_number },
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
data.dig(:organization, :projectV2, :id)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @see super
|
|
362
|
+
def initialize(**attrs)
|
|
363
|
+
@id = attrs.delete(:id)
|
|
364
|
+
@type = attrs.delete(:type)
|
|
365
|
+
@content_node_id = attrs.delete(:content_node_id)
|
|
366
|
+
super
|
|
367
|
+
@field_values ||= {}
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Moves this item to a new status column on its parent project.
|
|
371
|
+
#
|
|
372
|
+
# @param status [String] status name (e.g. "In Progress", "Done")
|
|
373
|
+
#
|
|
374
|
+
# @return [Hash] mutation result
|
|
375
|
+
#
|
|
376
|
+
def move_to!(status)
|
|
377
|
+
self.class.move_item(
|
|
378
|
+
project_number: project.number,
|
|
379
|
+
item_id: id,
|
|
380
|
+
status: status,
|
|
381
|
+
)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Assigns users to this item on its parent project.
|
|
385
|
+
#
|
|
386
|
+
# @param assignees [String, Array<String>] GitHub username(s)
|
|
387
|
+
#
|
|
388
|
+
# @return [void]
|
|
389
|
+
#
|
|
390
|
+
def assign!(assignees)
|
|
391
|
+
self.class.assign(
|
|
392
|
+
number: number,
|
|
393
|
+
content_node_id: content_node_id,
|
|
394
|
+
assignees: Array.wrap(assignees),
|
|
395
|
+
draft: draft?,
|
|
396
|
+
)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# @return [Boolean]
|
|
400
|
+
def draft?
|
|
401
|
+
type == 'DRAFT_ISSUE'
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
private
|
|
405
|
+
|
|
406
|
+
# Marks this record as persisted.
|
|
407
|
+
#
|
|
408
|
+
# @return [void]
|
|
409
|
+
#
|
|
410
|
+
def persisted!
|
|
411
|
+
@persisted = true
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|