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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +28 -0
  3. data/README.md +284 -0
  4. data/app/controllers/plan_my_stuff/application_controller.rb +76 -0
  5. data/app/controllers/plan_my_stuff/comments_controller.rb +82 -0
  6. data/app/controllers/plan_my_stuff/issues_controller.rb +145 -0
  7. data/app/controllers/plan_my_stuff/labels_controller.rb +30 -0
  8. data/app/controllers/plan_my_stuff/project_items_controller.rb +93 -0
  9. data/app/controllers/plan_my_stuff/projects_controller.rb +17 -0
  10. data/app/views/plan_my_stuff/comments/edit.html.erb +16 -0
  11. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +32 -0
  12. data/app/views/plan_my_stuff/issues/edit.html.erb +12 -0
  13. data/app/views/plan_my_stuff/issues/index.html.erb +37 -0
  14. data/app/views/plan_my_stuff/issues/new.html.erb +7 -0
  15. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +41 -0
  16. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +23 -0
  17. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +32 -0
  18. data/app/views/plan_my_stuff/issues/show.html.erb +58 -0
  19. data/app/views/plan_my_stuff/projects/index.html.erb +13 -0
  20. data/app/views/plan_my_stuff/projects/show.html.erb +101 -0
  21. data/config/routes.rb +25 -0
  22. data/lib/generators/plan_my_stuff/install/install_generator.rb +38 -0
  23. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +106 -0
  24. data/lib/generators/plan_my_stuff/views/views_generator.rb +22 -0
  25. data/lib/plan_my_stuff/application_record.rb +39 -0
  26. data/lib/plan_my_stuff/base_metadata.rb +136 -0
  27. data/lib/plan_my_stuff/client.rb +143 -0
  28. data/lib/plan_my_stuff/comment.rb +360 -0
  29. data/lib/plan_my_stuff/comment_metadata.rb +56 -0
  30. data/lib/plan_my_stuff/configuration.rb +139 -0
  31. data/lib/plan_my_stuff/custom_fields.rb +65 -0
  32. data/lib/plan_my_stuff/engine.rb +11 -0
  33. data/lib/plan_my_stuff/errors.rb +87 -0
  34. data/lib/plan_my_stuff/issue.rb +486 -0
  35. data/lib/plan_my_stuff/issue_metadata.rb +111 -0
  36. data/lib/plan_my_stuff/label.rb +59 -0
  37. data/lib/plan_my_stuff/markdown.rb +83 -0
  38. data/lib/plan_my_stuff/metadata_parser.rb +53 -0
  39. data/lib/plan_my_stuff/project.rb +504 -0
  40. data/lib/plan_my_stuff/project_item.rb +414 -0
  41. data/lib/plan_my_stuff/test_helpers.rb +501 -0
  42. data/lib/plan_my_stuff/user_resolver.rb +61 -0
  43. data/lib/plan_my_stuff/verifier.rb +102 -0
  44. data/lib/plan_my_stuff/version.rb +19 -0
  45. data/lib/plan_my_stuff.rb +69 -0
  46. data/lib/tasks/plan_my_stuff.rake +23 -0
  47. 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