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,486 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Wraps a GitHub issue with parsed PMS metadata and comments.
|
|
5
|
+
# Class methods provide the public API for CRUD operations.
|
|
6
|
+
#
|
|
7
|
+
# Follows an ActiveRecord-style pattern:
|
|
8
|
+
# - `Issue.new(**attrs)` creates an unpersisted instance
|
|
9
|
+
# - `Issue.create!` / `Issue.find` / `Issue.list` return persisted instances
|
|
10
|
+
# - `issue.save!` / `issue.update!` / `issue.reload` for persistence
|
|
11
|
+
class Issue < PlanMyStuff::ApplicationRecord
|
|
12
|
+
# @return [Integer] GitHub issue number
|
|
13
|
+
attr_reader :number
|
|
14
|
+
# @return [String] full body as stored on GitHub
|
|
15
|
+
attr_reader :raw_body
|
|
16
|
+
# @return [PlanMyStuff::IssueMetadata] parsed metadata (empty when no PMS metadata present)
|
|
17
|
+
attr_reader :metadata
|
|
18
|
+
|
|
19
|
+
# @return [String] issue title
|
|
20
|
+
attr_accessor :title
|
|
21
|
+
# @return [String] issue body without the metadata HTML comment
|
|
22
|
+
attr_writer :body
|
|
23
|
+
# @return [String] issue state ("open" or "closed")
|
|
24
|
+
attr_accessor :state
|
|
25
|
+
# @return [Array<String>] label names
|
|
26
|
+
attr_accessor :labels
|
|
27
|
+
# @return [String] resolved repo path (e.g. "Org/Repo")
|
|
28
|
+
attr_accessor :repo
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Creates a GitHub issue with PMS metadata embedded in the body.
|
|
32
|
+
#
|
|
33
|
+
# @param title [String]
|
|
34
|
+
# @param body [String]
|
|
35
|
+
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
36
|
+
# @param labels [Array<String>]
|
|
37
|
+
# @param user [Object, Integer] user object or user_id
|
|
38
|
+
# @param metadata [Hash] custom fields hash
|
|
39
|
+
# @param add_to_project [Boolean, Integer, nil]
|
|
40
|
+
# @param visibility_allowlist [Array<Integer>] user IDs for internal comment access
|
|
41
|
+
#
|
|
42
|
+
# @return [PlanMyStuff::Issue]
|
|
43
|
+
#
|
|
44
|
+
def create!(
|
|
45
|
+
title:,
|
|
46
|
+
body:,
|
|
47
|
+
repo: nil,
|
|
48
|
+
labels: [],
|
|
49
|
+
user: nil,
|
|
50
|
+
metadata: {},
|
|
51
|
+
add_to_project: nil,
|
|
52
|
+
visibility_allowlist: []
|
|
53
|
+
)
|
|
54
|
+
raise(ValidationError.new('body must be present', field: :body, expected_type: :string)) if body.blank?
|
|
55
|
+
|
|
56
|
+
client = PlanMyStuff.client
|
|
57
|
+
resolved_repo = client.resolve_repo(repo)
|
|
58
|
+
|
|
59
|
+
issue_metadata = IssueMetadata.build(
|
|
60
|
+
user: user,
|
|
61
|
+
custom_fields: metadata,
|
|
62
|
+
)
|
|
63
|
+
issue_metadata.visibility_allowlist = Array.wrap(visibility_allowlist)
|
|
64
|
+
|
|
65
|
+
serialized_body = MetadataParser.serialize(issue_metadata.to_h, '')
|
|
66
|
+
|
|
67
|
+
options = {}
|
|
68
|
+
options[:labels] = labels if labels.any?
|
|
69
|
+
|
|
70
|
+
result = client.rest(:create_issue, resolved_repo, title, serialized_body, **options)
|
|
71
|
+
|
|
72
|
+
issue = build(result, repo: resolved_repo)
|
|
73
|
+
|
|
74
|
+
if add_to_project.present?
|
|
75
|
+
project_number = resolve_project_number(add_to_project)
|
|
76
|
+
ProjectItem.create!(issue, project_number: project_number)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Comment.create!(
|
|
80
|
+
issue: issue,
|
|
81
|
+
body: body,
|
|
82
|
+
user: user,
|
|
83
|
+
visibility: issue_metadata.visibility.to_sym,
|
|
84
|
+
skip_responded: true,
|
|
85
|
+
issue_body: true,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
issue
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Updates an existing GitHub issue.
|
|
92
|
+
#
|
|
93
|
+
# @param number [Integer]
|
|
94
|
+
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
95
|
+
# @param title [String, nil]
|
|
96
|
+
# @param body [String, nil]
|
|
97
|
+
# @param metadata [Hash, nil] custom fields to merge into existing metadata
|
|
98
|
+
# @param labels [Array<String>, nil]
|
|
99
|
+
# @param state [Symbol, nil] :open or :closed
|
|
100
|
+
#
|
|
101
|
+
# @return [Object]
|
|
102
|
+
#
|
|
103
|
+
def update!(number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil)
|
|
104
|
+
client = PlanMyStuff.client
|
|
105
|
+
resolved_repo = client.resolve_repo(repo)
|
|
106
|
+
|
|
107
|
+
options = {}
|
|
108
|
+
options[:title] = title unless title.nil?
|
|
109
|
+
options[:labels] = labels unless labels.nil?
|
|
110
|
+
options[:state] = state.to_s unless state.nil?
|
|
111
|
+
options[:assignees] = Array.wrap(assignees) unless assignees.nil?
|
|
112
|
+
|
|
113
|
+
if metadata
|
|
114
|
+
current = client.rest(:issue, resolved_repo, number)
|
|
115
|
+
current_body = current.respond_to?(:body) ? current.body : current[:body]
|
|
116
|
+
parsed = MetadataParser.parse(current_body)
|
|
117
|
+
existing_metadata = parsed[:metadata]
|
|
118
|
+
|
|
119
|
+
merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
|
|
120
|
+
existing_metadata = existing_metadata.merge(metadata)
|
|
121
|
+
existing_metadata[:custom_fields] = merged_custom_fields
|
|
122
|
+
|
|
123
|
+
existing_metadata[:updated_at] = Time.now.utc.iso8601
|
|
124
|
+
options[:body] = MetadataParser.serialize(existing_metadata, '')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
update_body_comment(number, resolved_repo, body) if body
|
|
128
|
+
|
|
129
|
+
client.rest(:update_issue, resolved_repo, number, **options) if options.any?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Finds a single GitHub issue by number and parses its PMS metadata.
|
|
133
|
+
#
|
|
134
|
+
# @param number [Integer]
|
|
135
|
+
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
136
|
+
#
|
|
137
|
+
# @return [PlanMyStuff::Issue]
|
|
138
|
+
#
|
|
139
|
+
def find(number, repo: nil)
|
|
140
|
+
client = PlanMyStuff.client
|
|
141
|
+
resolved_repo = client.resolve_repo(repo)
|
|
142
|
+
|
|
143
|
+
github_issue = client.rest(:issue, resolved_repo, number)
|
|
144
|
+
|
|
145
|
+
if github_issue.respond_to?(:pull_request) && github_issue.pull_request
|
|
146
|
+
raise(Octokit::NotFound, { method: 'GET', url: "repos/#{resolved_repo}/issues/#{number}" })
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
build(github_issue, repo: resolved_repo)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Lists GitHub issues with optional filters and pagination.
|
|
153
|
+
#
|
|
154
|
+
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
155
|
+
# @param state [Symbol] :open, :closed, or :all
|
|
156
|
+
# @param labels [Array<String>]
|
|
157
|
+
# @param page [Integer]
|
|
158
|
+
# @param per_page [Integer]
|
|
159
|
+
#
|
|
160
|
+
# @return [Array<PlanMyStuff::Issue>]
|
|
161
|
+
#
|
|
162
|
+
def list(repo: nil, state: :open, labels: [], page: 1, per_page: 25)
|
|
163
|
+
client = PlanMyStuff.client
|
|
164
|
+
resolved_repo = client.resolve_repo(repo)
|
|
165
|
+
|
|
166
|
+
options = { state: state.to_s, page: page, per_page: per_page }
|
|
167
|
+
options[:labels] = labels.join(',') if labels.any?
|
|
168
|
+
|
|
169
|
+
github_issues = client.rest(:list_issues, resolved_repo, **options)
|
|
170
|
+
github_issues.filter_map do |gi|
|
|
171
|
+
next if gi.respond_to?(:pull_request) && gi.pull_request
|
|
172
|
+
|
|
173
|
+
build(gi, repo: resolved_repo)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Adds user IDs to the visibility allowlist of an issue's metadata.
|
|
178
|
+
#
|
|
179
|
+
# @param number [Integer]
|
|
180
|
+
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
181
|
+
# @param user_ids [Array<Integer>]
|
|
182
|
+
#
|
|
183
|
+
# @return [Object] Octokit response
|
|
184
|
+
#
|
|
185
|
+
def add_viewers(number:, user_ids:, repo: nil)
|
|
186
|
+
modify_allowlist(number: number, repo: repo) do |allowlist|
|
|
187
|
+
allowlist | Array.wrap(user_ids)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Removes user IDs from the visibility allowlist of an issue's metadata.
|
|
192
|
+
#
|
|
193
|
+
# @param number [Integer]
|
|
194
|
+
# @param repo [Symbol, String, nil] defaults to config.default_repo
|
|
195
|
+
# @param user_ids [Array<Integer>]
|
|
196
|
+
#
|
|
197
|
+
# @return [Object] Octokit response
|
|
198
|
+
#
|
|
199
|
+
def remove_viewers(number:, user_ids:, repo: nil)
|
|
200
|
+
modify_allowlist(number: number, repo: repo) do |allowlist|
|
|
201
|
+
allowlist - Array.wrap(user_ids)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
# Hydrates an Issue from a GitHub API response.
|
|
208
|
+
#
|
|
209
|
+
# @param github_issue [Object] Octokit issue response
|
|
210
|
+
# @param repo [String] resolved repo path
|
|
211
|
+
#
|
|
212
|
+
# @return [PlanMyStuff::Issue]
|
|
213
|
+
#
|
|
214
|
+
def build(github_issue, repo:)
|
|
215
|
+
issue = new
|
|
216
|
+
issue.__send__(:hydrate_from_github, github_issue, repo: repo)
|
|
217
|
+
issue
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @return [Integer]
|
|
221
|
+
def resolve_project_number(add_to_project)
|
|
222
|
+
return add_to_project unless add_to_project == true
|
|
223
|
+
|
|
224
|
+
PlanMyStuff.configuration.default_project_number ||
|
|
225
|
+
raise(ArgumentError, 'add_to_project: true requires config.default_project_number to be set')
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Reads an issue's metadata, yields the allowlist for modification,
|
|
229
|
+
# and PATCHes the issue body with the updated allowlist.
|
|
230
|
+
#
|
|
231
|
+
# @param number [Integer]
|
|
232
|
+
# @param repo [Symbol, String, nil]
|
|
233
|
+
#
|
|
234
|
+
# @return [Object] Octokit response
|
|
235
|
+
#
|
|
236
|
+
def modify_allowlist(number:, repo:)
|
|
237
|
+
client = PlanMyStuff.client
|
|
238
|
+
resolved_repo = client.resolve_repo(repo)
|
|
239
|
+
|
|
240
|
+
current = client.rest(:issue, resolved_repo, number)
|
|
241
|
+
current_body = current.respond_to?(:body) ? current.body : current[:body]
|
|
242
|
+
parsed = MetadataParser.parse(current_body)
|
|
243
|
+
|
|
244
|
+
existing_metadata = parsed[:metadata]
|
|
245
|
+
allowlist = Array.wrap(existing_metadata[:visibility_allowlist])
|
|
246
|
+
existing_metadata[:visibility_allowlist] = yield(allowlist)
|
|
247
|
+
existing_metadata[:updated_at] = Time.now.utc.iso8601
|
|
248
|
+
|
|
249
|
+
new_body = MetadataParser.serialize(existing_metadata, parsed[:body])
|
|
250
|
+
client.rest(:update_issue, resolved_repo, number, body: new_body)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Finds the first PMS comment on an issue and updates its body content,
|
|
254
|
+
# preserving the comment header and metadata.
|
|
255
|
+
#
|
|
256
|
+
# @param number [Integer] issue number
|
|
257
|
+
# @param resolved_repo [String] resolved repo path
|
|
258
|
+
# @param new_body [String] new body content
|
|
259
|
+
#
|
|
260
|
+
# @return [void]
|
|
261
|
+
#
|
|
262
|
+
def update_body_comment(number, resolved_repo, new_body)
|
|
263
|
+
issue = find(number, repo: resolved_repo)
|
|
264
|
+
body_comment = issue.body_comment
|
|
265
|
+
raise(PlanMyStuff::Error, "No body comment found on issue ##{number}") if body_comment.nil?
|
|
266
|
+
|
|
267
|
+
header = body_comment.header
|
|
268
|
+
|
|
269
|
+
updated_body =
|
|
270
|
+
if header.present?
|
|
271
|
+
"#{header}\n\n#{new_body}"
|
|
272
|
+
else
|
|
273
|
+
new_body
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
body_comment.update!(body: updated_body)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def initialize(**attrs)
|
|
281
|
+
@number = attrs.delete(:number)
|
|
282
|
+
@raw_body = nil
|
|
283
|
+
@metadata = IssueMetadata.new
|
|
284
|
+
super
|
|
285
|
+
@labels ||= []
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Persists the issue. Creates if new, updates if persisted.
|
|
289
|
+
#
|
|
290
|
+
# @raise [PlanMyStuff::StaleObjectError] on update if stale
|
|
291
|
+
#
|
|
292
|
+
# @return [self]
|
|
293
|
+
#
|
|
294
|
+
def save!
|
|
295
|
+
if new_record?
|
|
296
|
+
created = self.class.create!(
|
|
297
|
+
title: title,
|
|
298
|
+
body: body,
|
|
299
|
+
repo: repo,
|
|
300
|
+
labels: labels || [],
|
|
301
|
+
)
|
|
302
|
+
hydrate_from_issue(created)
|
|
303
|
+
else
|
|
304
|
+
update!(body: body, state: state, labels: labels)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
self
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Updates this issue on GitHub. Raises StaleObjectError if the remote
|
|
311
|
+
# has been modified since this instance was loaded.
|
|
312
|
+
#
|
|
313
|
+
# @param attrs [Hash] attributes to update (title:, body:, state:, labels:, metadata:)
|
|
314
|
+
#
|
|
315
|
+
# @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
|
|
316
|
+
#
|
|
317
|
+
# @return [self]
|
|
318
|
+
#
|
|
319
|
+
def update!(**attrs)
|
|
320
|
+
raise_if_stale!
|
|
321
|
+
|
|
322
|
+
self.class.update!(
|
|
323
|
+
number: number,
|
|
324
|
+
repo: repo,
|
|
325
|
+
**attrs,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
reload
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Re-fetches this issue from GitHub and updates all local attributes.
|
|
332
|
+
#
|
|
333
|
+
# @return [self]
|
|
334
|
+
#
|
|
335
|
+
def reload
|
|
336
|
+
fresh = self.class.find(number, repo: repo)
|
|
337
|
+
hydrate_from_issue(fresh)
|
|
338
|
+
self
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Lazy-loads and memoizes comments from the GitHub API.
|
|
342
|
+
#
|
|
343
|
+
# @return [Array<PlanMyStuff::Comment>]
|
|
344
|
+
#
|
|
345
|
+
def comments
|
|
346
|
+
@comments ||= load_comments
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# @return [Boolean]
|
|
350
|
+
def pms_issue?
|
|
351
|
+
metadata.schema_version.present?
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# @return [Array<PlanMyStuff::Comment>] only comments created via PMS
|
|
355
|
+
def pms_comments
|
|
356
|
+
comments.select(&:pms_comment?)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Returns the comment marked as the issue body, if any.
|
|
360
|
+
#
|
|
361
|
+
# @return [PlanMyStuff::Comment, nil]
|
|
362
|
+
#
|
|
363
|
+
def body_comment
|
|
364
|
+
pms_comments.find { |c| c.metadata.issue_body? }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Returns the issue body content. For PMS issues, this is the body
|
|
368
|
+
# from the body comment (stripped of its header). Falls back to the
|
|
369
|
+
# parsed issue body for non-PMS issues.
|
|
370
|
+
#
|
|
371
|
+
# @return [String, nil]
|
|
372
|
+
#
|
|
373
|
+
def body
|
|
374
|
+
return @body if new_record?
|
|
375
|
+
|
|
376
|
+
return @body unless pms_issue?
|
|
377
|
+
|
|
378
|
+
bc = body_comment
|
|
379
|
+
return bc.body_without_header if bc.present?
|
|
380
|
+
|
|
381
|
+
@body
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Delegates visibility check to metadata.
|
|
385
|
+
# Non-PMS issues are always visible.
|
|
386
|
+
#
|
|
387
|
+
# @param user [Object, Integer] user object or user_id
|
|
388
|
+
#
|
|
389
|
+
# @return [Boolean]
|
|
390
|
+
#
|
|
391
|
+
def visible_to?(user)
|
|
392
|
+
if pms_issue?
|
|
393
|
+
metadata.visible_to?(user)
|
|
394
|
+
else
|
|
395
|
+
UserResolver.support?(UserResolver.resolve(user))
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
private
|
|
400
|
+
|
|
401
|
+
# Populates this instance from a GitHub API response.
|
|
402
|
+
#
|
|
403
|
+
# @param github_issue [Object] Octokit issue response
|
|
404
|
+
# @param repo [String] resolved repo path
|
|
405
|
+
#
|
|
406
|
+
# @return [void]
|
|
407
|
+
#
|
|
408
|
+
def hydrate_from_github(github_issue, repo:)
|
|
409
|
+
@number = read_field(github_issue, :number)
|
|
410
|
+
@title = read_field(github_issue, :title)
|
|
411
|
+
@state = read_field(github_issue, :state)
|
|
412
|
+
@raw_body = read_field(github_issue, :body) || ''
|
|
413
|
+
@labels = extract_labels(github_issue)
|
|
414
|
+
@repo = repo
|
|
415
|
+
|
|
416
|
+
parsed = MetadataParser.parse(@raw_body)
|
|
417
|
+
@metadata = IssueMetadata.from_hash(parsed[:metadata])
|
|
418
|
+
@body = parsed[:body]
|
|
419
|
+
@persisted = true
|
|
420
|
+
@comments = nil
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Copies attributes from another Issue instance into self.
|
|
424
|
+
#
|
|
425
|
+
# @param other [PlanMyStuff::Issue]
|
|
426
|
+
#
|
|
427
|
+
# @return [void]
|
|
428
|
+
#
|
|
429
|
+
def hydrate_from_issue(other)
|
|
430
|
+
@number = other.number
|
|
431
|
+
@title = other.title
|
|
432
|
+
@state = other.state
|
|
433
|
+
@body = other.instance_variable_get(:@body)
|
|
434
|
+
@raw_body = other.raw_body
|
|
435
|
+
@labels = other.labels
|
|
436
|
+
@repo = other.repo
|
|
437
|
+
@metadata = other.metadata
|
|
438
|
+
@persisted = true
|
|
439
|
+
@comments = nil
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Raises StaleObjectError if the remote issue has been modified
|
|
443
|
+
# since this instance was loaded.
|
|
444
|
+
#
|
|
445
|
+
# @raise [PlanMyStuff::StaleObjectError]
|
|
446
|
+
#
|
|
447
|
+
# @return [void]
|
|
448
|
+
#
|
|
449
|
+
def raise_if_stale!
|
|
450
|
+
return if new_record?
|
|
451
|
+
return if metadata.updated_at.nil?
|
|
452
|
+
|
|
453
|
+
remote = self.class.find(number, repo: repo)
|
|
454
|
+
remote_time = remote.metadata.updated_at
|
|
455
|
+
local_time = metadata.updated_at
|
|
456
|
+
|
|
457
|
+
return if remote_time.nil?
|
|
458
|
+
return if local_time && remote_time.to_i == local_time.to_i
|
|
459
|
+
|
|
460
|
+
raise(StaleObjectError.new(
|
|
461
|
+
"Issue ##{number} has been modified remotely",
|
|
462
|
+
local_updated_at: local_time,
|
|
463
|
+
remote_updated_at: remote_time,
|
|
464
|
+
))
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# @return [Array<String>]
|
|
468
|
+
def extract_labels(github_issue)
|
|
469
|
+
raw = read_field(github_issue, :labels) || []
|
|
470
|
+
raw.map { |label| label_name(label) }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# @return [String]
|
|
474
|
+
def label_name(label)
|
|
475
|
+
return label.name if label.respond_to?(:name)
|
|
476
|
+
return label[:name] || label['name'] if label.is_a?(Hash)
|
|
477
|
+
|
|
478
|
+
label.to_s
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# @return [Array<PlanMyStuff::Comment>]
|
|
482
|
+
def load_comments
|
|
483
|
+
Comment.list(issue: self)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
class IssueMetadata < BaseMetadata
|
|
5
|
+
# @return [Time, nil] first support action timestamp, nil until set
|
|
6
|
+
attr_accessor :responded_at
|
|
7
|
+
# @return [String, nil] user-facing URL in the consuming app
|
|
8
|
+
attr_accessor :issues_url
|
|
9
|
+
# @return [Boolean] whether this issue appears on the priority dashboard
|
|
10
|
+
attr_accessor :priority_list
|
|
11
|
+
# @return [Integer] sort order on priority dashboard (-1 = unranked)
|
|
12
|
+
attr_accessor :priority_list_priority
|
|
13
|
+
# @return [Array<Integer>] user IDs of non-support users allowed to view internal comments
|
|
14
|
+
attr_accessor :visibility_allowlist
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Builds an IssueMetadata from a parsed hash (e.g. from MetadataParser)
|
|
18
|
+
#
|
|
19
|
+
# @param hash [Hash]
|
|
20
|
+
#
|
|
21
|
+
# @return [IssueMetadata]
|
|
22
|
+
#
|
|
23
|
+
def from_hash(hash)
|
|
24
|
+
metadata = new
|
|
25
|
+
apply_common_from_hash(metadata, hash)
|
|
26
|
+
|
|
27
|
+
metadata.responded_at = parse_time(hash[:responded_at])
|
|
28
|
+
metadata.issues_url = hash[:issues_url]
|
|
29
|
+
metadata.priority_list = hash.fetch(:priority_list, false)
|
|
30
|
+
metadata.priority_list_priority = hash.fetch(:priority_list_priority, -1)
|
|
31
|
+
metadata.visibility_allowlist = Array.wrap(hash[:visibility_allowlist])
|
|
32
|
+
|
|
33
|
+
metadata
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Builds a new IssueMetadata for issue creation, auto-filling gem defaults
|
|
37
|
+
#
|
|
38
|
+
# @param user [Object, Integer] user object or user_id
|
|
39
|
+
# @param custom_fields [Hash] app-defined field values
|
|
40
|
+
#
|
|
41
|
+
# @return [IssueMetadata]
|
|
42
|
+
#
|
|
43
|
+
def build(user:, visibility: 'public', custom_fields: {})
|
|
44
|
+
metadata = new
|
|
45
|
+
apply_common_build(metadata, user: user, visibility: visibility, custom_fields_data: custom_fields)
|
|
46
|
+
|
|
47
|
+
metadata.responded_at = nil
|
|
48
|
+
metadata.issues_url = build_issues_url(PlanMyStuff.configuration)
|
|
49
|
+
metadata.priority_list = false
|
|
50
|
+
metadata.priority_list_priority = -1
|
|
51
|
+
metadata.visibility_allowlist = []
|
|
52
|
+
|
|
53
|
+
metadata
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# @return [String, nil]
|
|
59
|
+
def build_issues_url(config)
|
|
60
|
+
return if config.issues_url_prefix.nil?
|
|
61
|
+
|
|
62
|
+
config.issues_url_prefix.to_s
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize
|
|
67
|
+
super
|
|
68
|
+
@priority_list = false
|
|
69
|
+
@priority_list_priority = -1
|
|
70
|
+
@visibility_allowlist = []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def priority_list?
|
|
75
|
+
!!priority_list
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def responded?
|
|
80
|
+
!responded_at.nil?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Checks whether a user can see this issue's internal content.
|
|
84
|
+
# Public issues are always visible. Internal issues are visible if the
|
|
85
|
+
# user is support staff or their ID is in the visibility_allowlist.
|
|
86
|
+
#
|
|
87
|
+
# @param user [Object, Integer] user object or user_id
|
|
88
|
+
#
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
#
|
|
91
|
+
def visible_to?(user)
|
|
92
|
+
return true if public?
|
|
93
|
+
|
|
94
|
+
resolved = UserResolver.resolve(user)
|
|
95
|
+
return true if UserResolver.support?(resolved)
|
|
96
|
+
|
|
97
|
+
visibility_allowlist.include?(UserResolver.user_id(resolved))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Hash]
|
|
101
|
+
def to_h
|
|
102
|
+
super.merge(
|
|
103
|
+
responded_at: format_time(responded_at),
|
|
104
|
+
issues_url: issues_url,
|
|
105
|
+
priority_list: priority_list,
|
|
106
|
+
priority_list_priority: priority_list_priority,
|
|
107
|
+
visibility_allowlist: visibility_allowlist,
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Wraps a GitHub label with a reference to its parent issue.
|
|
5
|
+
# Class methods provide the public API for add/remove operations.
|
|
6
|
+
class Label < PlanMyStuff::ApplicationRecord
|
|
7
|
+
# @return [String] label name
|
|
8
|
+
attr_accessor :name
|
|
9
|
+
# @return [PlanMyStuff::Issue] parent issue
|
|
10
|
+
attr_accessor :issue
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Adds labels to a GitHub issue.
|
|
14
|
+
#
|
|
15
|
+
# @param issue [PlanMyStuff::Issue] parent issue
|
|
16
|
+
# @param labels [Array<String>]
|
|
17
|
+
#
|
|
18
|
+
# @return [Array<PlanMyStuff::Label>]
|
|
19
|
+
#
|
|
20
|
+
def add(issue:, labels:)
|
|
21
|
+
result = PlanMyStuff.client.rest(
|
|
22
|
+
:add_labels_to_an_issue, issue.repo, issue.number, labels,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
result.map { |gh_label| build(gh_label, issue: issue) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Removes labels from a GitHub issue.
|
|
29
|
+
#
|
|
30
|
+
# @param issue [PlanMyStuff::Issue] parent issue
|
|
31
|
+
# @param labels [Array<String>]
|
|
32
|
+
#
|
|
33
|
+
# @return [Array<Array<PlanMyStuff::Label>>] results of each removal
|
|
34
|
+
#
|
|
35
|
+
def remove(issue:, labels:)
|
|
36
|
+
Array.wrap(labels).map do |label|
|
|
37
|
+
result = PlanMyStuff.client.rest(:remove_label, issue.repo, issue.number, label)
|
|
38
|
+
result.map { |gh_label| build(gh_label, issue: issue) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Hydrates a Label from a GitHub API response.
|
|
45
|
+
#
|
|
46
|
+
# @param github_label [Object] Octokit label response
|
|
47
|
+
# @param issue [PlanMyStuff::Issue] parent issue
|
|
48
|
+
#
|
|
49
|
+
# @return [PlanMyStuff::Label]
|
|
50
|
+
#
|
|
51
|
+
def build(github_label, issue:)
|
|
52
|
+
label_name = github_label.respond_to?(:name) ? github_label.name : github_label[:name]
|
|
53
|
+
label = new(name: label_name, issue: issue)
|
|
54
|
+
label.instance_variable_set(:@persisted, true)
|
|
55
|
+
label
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|