plan_my_stuff 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. metadata +99 -4
@@ -9,27 +9,80 @@ module PlanMyStuff
9
9
  # - `Issue.create!` / `Issue.find` / `Issue.list` return persisted instances
10
10
  # - `issue.save!` / `issue.update!` / `issue.reload` for persistence
11
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
12
+ # Value object returned by +Issue.list_page_info+: the fetched issues plus pagination metadata read from the
13
+ # +Link+ header of the +list_issues+ response. +:total_pages+ is intentionally absent -- GitHub's issues endpoint
14
+ # is cursor-paginated and never advertises +rel="last"+.
15
+ #
16
+ # @!attribute [r] issues
17
+ # @return [Array<PlanMyStuff::Issue>]
18
+ # @!attribute [r] page
19
+ # @return [Integer] echo of the requested page
20
+ # @!attribute [r] per_page
21
+ # @return [Integer] echo of the requested per_page
22
+ PageInfo = Data.define(:issues, :page, :per_page, :has_next, :has_prev) do
23
+ alias_method :has_next?, :has_next
24
+ alias_method :has_prev?, :has_prev
25
+ end
26
+
27
+ include PlanMyStuff::IssueExtractions::Approvals
28
+ include PlanMyStuff::IssueExtractions::Links
29
+ include PlanMyStuff::IssueExtractions::Viewers
30
+ include PlanMyStuff::IssueExtractions::Waiting
31
+
32
+ # @return [Integer, nil] GitHub issue number
33
+ attribute :number, :integer
34
+ # @return [String, nil] full body as stored on GitHub
35
+ attribute :raw_body, :string
16
36
  # @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
37
+ attribute :metadata, default: -> { PlanMyStuff::IssueMetadata.new }
38
+ # @return [String, nil] issue title
39
+ attribute :title, :string
40
+ # @return [String, nil] issue state ("open" or "closed")
41
+ attribute :state, :string
25
42
  # @return [Array<String>] label names
26
- attr_accessor :labels
27
- # @return [String] resolved repo path (e.g. "Org/Repo")
28
- attr_accessor :repo
43
+ attribute :labels, default: -> { [] }
44
+ # @return [Time, nil] GitHub's updated_at timestamp
45
+ attribute :updated_at
46
+ # @return [Time, nil] GitHub's created_at timestamp; settable on unpersisted issues for use with +Issue.import+
47
+ attribute :created_at
48
+ # @return [Time, nil] GitHub's closed_at timestamp (nil while open)
49
+ attribute :closed_at
50
+ # @return [Boolean] GitHub's +locked+ flag; +true+ for archived or manually-locked issues (no new comments)
51
+ attribute :locked, :boolean, default: false
52
+ alias locked? locked
53
+ # @return [PlanMyStuff::Repo, nil]
54
+ attribute :repo
55
+ # @return [String, nil] issue body (user-visible content, separate from metadata)
56
+ attribute :body, :string
57
+ # @return [String, nil] GitHub issue type name (e.g. +"Bug"+, +"Feature"+) or +nil+ when no type is assigned. Read
58
+ # from the nested +type.name+ field on the REST response. Settable via the +issue_type:+ kwarg on
59
+ # +Issue.create!+ / +Issue.update!+.
60
+ attribute :issue_type, :string
61
+
62
+ # Sentinel default for the +issue_type:+ kwarg on +Issue.update!+. Lets the class method differentiate "kwarg not
63
+ # provided" (don't touch the type) from "kwarg explicitly set to +nil+" (clear the type). +nil+ alone can't carry
64
+ # that distinction so we need an object identity check.
65
+ ISSUE_TYPE_UNCHANGED = Object.new.freeze
66
+ private_constant :ISSUE_TYPE_UNCHANGED
67
+
68
+ # Symbol nicknames for the seven GitHub native issue types the gem knows about. Resolved to canonical names which
69
+ # then pass through +config.issue_types+ for org-specific renames.
70
+ ISSUE_TYPE_NICKNAMES = {
71
+ bug: 'Bug',
72
+ feature: 'Feature',
73
+ it_issue: 'IT Issue / Hardware',
74
+ other: 'Other',
75
+ performance: 'Performance',
76
+ question: 'Question',
77
+ task: 'Task',
78
+ }.freeze
79
+ private_constant :ISSUE_TYPE_NICKNAMES
29
80
 
30
81
  class << self
31
82
  # Creates a GitHub issue with PMS metadata embedded in the body.
32
83
  #
84
+ # @raise [PlanMyStuff::ValidationError] when body is blank
85
+ #
33
86
  # @param title [String]
34
87
  # @param body [String]
35
88
  # @param repo [Symbol, String, nil] defaults to config.default_repo
@@ -37,7 +90,18 @@ module PlanMyStuff
37
90
  # @param user [Object, Integer] user object or user_id
38
91
  # @param metadata [Hash] custom fields hash
39
92
  # @param add_to_project [Boolean, Integer, nil]
93
+ # @param visibility [String] "public" or "internal"
40
94
  # @param visibility_allowlist [Array<Integer>] user IDs for internal comment access
95
+ # @param issue_type [String, nil] GitHub issue type name (e.g. +"Bug"+, +"Feature"+). Must match a type
96
+ # configured on the org. +nil+ creates the issue with no type.
97
+ # @param issue_fields [Hash{String,Symbol => Object,nil}, nil] GitHub Issue Field values to apply after the
98
+ # issue is created. Delegates to +#set_issue_fields!+, so the same coercion rules and
99
+ # +IssueFieldsNotEnabledError+ behavior apply. +nil+ or an empty hash is a no-op.
100
+ # @param attachments [Array] files to upload to +config.attachment_repo+ and record on the body comment. Each
101
+ # entry may be an uploaded-file object responding to +#path+ and +#original_filename+ (e.g. Rails
102
+ # +ActionDispatch::Http::UploadedFile+), a String/Pathname path to a local file, or a pre-built
103
+ # +PlanMyStuff::Attachment+ instance (passthrough, no re-upload). Forwarded to the body comment's
104
+ # +attachments:+ kwarg; see +Comment.create!+ for full detail.
41
105
  #
42
106
  # @return [PlanMyStuff::Issue]
43
107
  #
@@ -49,100 +113,201 @@ module PlanMyStuff
49
113
  user: nil,
50
114
  metadata: {},
51
115
  add_to_project: nil,
52
- visibility_allowlist: []
116
+ visibility: 'public',
117
+ visibility_allowlist: [],
118
+ issue_type: nil,
119
+ issue_fields: nil,
120
+ attachments: []
53
121
  )
54
- raise(ValidationError.new('body must be present', field: :body, expected_type: :string)) if body.blank?
122
+ if issue_fields.present? && !PlanMyStuff.configuration.issue_fields_enabled
123
+ raise(PlanMyStuff::IssueFieldsNotEnabledError)
124
+ end
125
+
126
+ if issue_fields.present?
127
+ issue_fields = issue_fields.to_h.transform_keys(&:to_s)
128
+ issue_fields['Issue Status'] = 'Submitted' if issue_fields['Issue Status'].blank?
129
+ end
130
+
131
+ if body.blank?
132
+ raise(PlanMyStuff::ValidationError.new('body must be present', field: :body, expected_type: :string))
133
+ end
55
134
 
56
135
  client = PlanMyStuff.client
57
- resolved_repo = client.resolve_repo(repo)
136
+ resolved_repo = client.resolve_repo!(repo)
58
137
 
59
- issue_metadata = IssueMetadata.build(
138
+ issue_metadata = PlanMyStuff::IssueMetadata.build(
60
139
  user: user,
140
+ visibility: visibility,
61
141
  custom_fields: metadata,
62
142
  )
63
143
  issue_metadata.visibility_allowlist = Array.wrap(visibility_allowlist)
144
+ issue_metadata.validate_custom_fields!
145
+
146
+ serialized_body = PlanMyStuff::MetadataParser.serialize!(issue_metadata.to_h, '')
64
147
 
65
- serialized_body = MetadataParser.serialize(issue_metadata.to_h, '')
148
+ resolved_type = resolve_issue_type!(issue_type)
66
149
 
67
150
  options = {}
68
- options[:labels] = labels if labels.any?
151
+ options[:labels] = labels if labels.present?
152
+ options[:type] = resolved_type if resolved_type.present?
69
153
 
70
154
  result = client.rest(:create_issue, resolved_repo, title, serialized_body, **options)
155
+ number = read_field(result, :number)
156
+ store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
157
+
158
+ link_body = visible_body_for(number, resolved_repo)
159
+ if link_body.present?
160
+ result = client.rest(
161
+ :update_issue,
162
+ resolved_repo,
163
+ number,
164
+ body: PlanMyStuff::MetadataParser.serialize!(issue_metadata.to_h, link_body),
165
+ )
166
+ store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
167
+ end
71
168
 
72
- issue = build(result, repo: resolved_repo)
169
+ issue = find(number, repo: resolved_repo)
73
170
 
74
171
  if add_to_project.present?
75
- project_number = resolve_project_number(add_to_project)
76
- ProjectItem.create!(issue, project_number: project_number)
172
+ project_number = resolve_project_number!(add_to_project)
173
+ PlanMyStuff::ProjectItem.create!(issue, project_number: project_number)
77
174
  end
78
175
 
79
- Comment.create!(
176
+ PlanMyStuff::Comment.create!(
80
177
  issue: issue,
81
178
  body: body,
82
179
  user: user,
83
180
  visibility: issue_metadata.visibility.to_sym,
84
181
  skip_responded: true,
85
182
  issue_body: true,
183
+ attachments: attachments,
86
184
  )
87
185
 
186
+ issue.set_issue_fields!(issue_fields) if issue_fields.present?
187
+
188
+ issue.reload
189
+ PlanMyStuff::Notifications.instrument('issue_created', issue, user: user)
88
190
  issue
89
191
  end
90
192
 
91
193
  # Updates an existing GitHub issue.
92
194
  #
195
+ # +metadata:+ accepts either:
196
+ # - a +PlanMyStuff::IssueMetadata+ instance - treated as the
197
+ # full authoritative metadata and serialized as-is (used by
198
+ # instance +save!+/+update!+ so local +@metadata+ mutations
199
+ # like +metadata.commit_sha = ...+ actually persist).
200
+ # - a +Hash+ - patch-style merge against the CURRENT remote
201
+ # metadata. Top-level keys are merged in; +:custom_fields+
202
+ # is merged separately so unrelated fields stay intact.
203
+ #
93
204
  # @param number [Integer]
94
205
  # @param repo [Symbol, String, nil] defaults to config.default_repo
95
206
  # @param title [String, nil]
96
207
  # @param body [String, nil]
97
- # @param metadata [Hash, nil] custom fields to merge into existing metadata
208
+ # @param metadata [PlanMyStuff::IssueMetadata, Hash, nil]
98
209
  # @param labels [Array<String>, nil]
99
210
  # @param state [Symbol, nil] :open or :closed
211
+ # @param assignees [Array<String>, String, nil] GitHub logins
212
+ # @param issue_type [String, nil] GitHub issue type name. Pass a String to set, +nil+ to clear, or omit the
213
+ # kwarg to leave the current type untouched. (+nil+-vs-omitted is differentiated by the private
214
+ # +ISSUE_TYPE_UNCHANGED+ sentinel.)
215
+ # @param issue_fields [Hash{String,Symbol => Object,nil}, nil] GitHub Issue Field values to apply after the
216
+ # PATCH (or instead of it, when no other attrs are provided). Delegates to +#set_issue_fields!+, so the same
217
+ # coercion rules and +IssueFieldsNotEnabledError+ behavior apply. +nil+ or an empty hash is a no-op.
100
218
  #
101
219
  # @return [Object]
102
220
  #
103
- def update!(number:, repo: nil, title: nil, body: nil, metadata: nil, labels: nil, state: nil, assignees: nil)
221
+ def update!(
222
+ number:,
223
+ repo: nil,
224
+ title: nil,
225
+ body: nil,
226
+ metadata: nil,
227
+ labels: nil,
228
+ state: nil,
229
+ assignees: nil,
230
+ issue_type: ISSUE_TYPE_UNCHANGED,
231
+ issue_fields: nil
232
+ )
104
233
  client = PlanMyStuff.client
105
- resolved_repo = client.resolve_repo(repo)
234
+ resolved_repo = client.resolve_repo!(repo)
106
235
 
107
236
  options = {}
108
237
  options[:title] = title unless title.nil?
109
238
  options[:labels] = labels unless labels.nil?
110
239
  options[:state] = state.to_s unless state.nil?
111
240
  options[:assignees] = Array.wrap(assignees) unless assignees.nil?
112
-
113
- if metadata
241
+ options[:type] = resolve_issue_type!(issue_type) unless issue_type.equal?(ISSUE_TYPE_UNCHANGED)
242
+
243
+ case metadata
244
+ when PlanMyStuff::IssueMetadata
245
+ metadata.validate_custom_fields!
246
+ options[:body] =
247
+ PlanMyStuff::MetadataParser.serialize!(metadata.to_h, visible_body_for(number, resolved_repo))
248
+ when Hash
114
249
  current = client.rest(:issue, resolved_repo, number)
115
250
  current_body = current.respond_to?(:body) ? current.body : current[:body]
116
- parsed = MetadataParser.parse(current_body)
251
+ parsed = PlanMyStuff::MetadataParser.parse(current_body)
117
252
  existing_metadata = parsed[:metadata]
118
253
 
119
254
  merged_custom_fields = (existing_metadata[:custom_fields] || {}).merge(metadata[:custom_fields] || {})
120
255
  existing_metadata = existing_metadata.merge(metadata)
121
256
  existing_metadata[:custom_fields] = merged_custom_fields
257
+ PlanMyStuff::CustomFields.new(
258
+ PlanMyStuff.configuration.custom_fields_for(:issue),
259
+ merged_custom_fields,
260
+ ).validate!
122
261
 
123
- existing_metadata[:updated_at] = Time.now.utc.iso8601
124
- options[:body] = MetadataParser.serialize(existing_metadata, '')
262
+ options[:body] =
263
+ PlanMyStuff::MetadataParser.serialize!(existing_metadata, visible_body_for(number, resolved_repo))
125
264
  end
126
265
 
127
- update_body_comment(number, resolved_repo, body) if body
266
+ update_body_comment!(number, resolved_repo, body) if body
267
+
268
+ updated_issue = find(number, repo: resolved_repo).set_issue_fields!(issue_fields) if issue_fields.present?
269
+ return updated_issue if options.none?
128
270
 
129
- client.rest(:update_issue, resolved_repo, number, **options) if options.any?
271
+ result = client.rest(:update_issue, resolved_repo, number, **options)
272
+ store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
273
+ result
130
274
  end
131
275
 
132
276
  # Finds a single GitHub issue by number and parses its PMS metadata.
133
277
  #
134
- # @param number [Integer]
135
- # @param repo [Symbol, String, nil] defaults to config.default_repo
278
+ # Accepts a numeric id (Integer or all-digit String) plus an optional +repo:+ kwarg, or a nickname-id String
279
+ # (e.g. +"Rawr-1234"+) where the repo is encoded in the prefix and +repo:+ is ignored.
280
+ #
281
+ # @raise [Octokit::NotFound] when the issue number resolves to a pull request
282
+ # @raise [ArgumentError] when a nickname-id String references an unknown repo nickname
283
+ #
284
+ # @param id_or_number [Integer, String]
285
+ # @param repo [Symbol, String, nil] defaults to config.default_repo; ignored when +id_or_number+ is a nickname id
136
286
  #
137
287
  # @return [PlanMyStuff::Issue]
138
288
  #
139
- def find(number, repo: nil)
289
+ def find(id_or_number, repo: nil)
290
+ number, resolved_repo = resolve_find_args(id_or_number, repo)
140
291
  client = PlanMyStuff.client
141
- resolved_repo = client.resolve_repo(repo)
142
-
143
- github_issue = client.rest(:issue, resolved_repo, number)
144
292
 
145
- if github_issue.respond_to?(:pull_request) && github_issue.pull_request
293
+ github_issue =
294
+ fetch_with_etag_cache(
295
+ client,
296
+ resolved_repo,
297
+ number,
298
+ rest_method: :issue,
299
+ cache_reader: :read_issue,
300
+ cache_writer: :write_issue,
301
+ )
302
+
303
+ pull_request =
304
+ if github_issue.respond_to?(:pull_request)
305
+ github_issue.pull_request
306
+ elsif github_issue.is_a?(Hash)
307
+ github_issue[:pull_request] || github_issue['pull_request']
308
+ end
309
+
310
+ if pull_request
146
311
  raise(Octokit::NotFound, { method: 'GET', url: "repos/#{resolved_repo}/issues/#{number}" })
147
312
  end
148
313
 
@@ -151,59 +316,452 @@ module PlanMyStuff
151
316
 
152
317
  # Lists GitHub issues with optional filters and pagination.
153
318
  #
319
+ # +issue_fields:+ is a Hash keyed by GitHub Issue Field display name (String / Symbol). Each value is either
320
+ # a scalar (equality match -- Date / Time are emitted as ISO 8601, everything else as +to_s+) or a +Range+ for
321
+ # numeric / date bounds:
322
+ #
323
+ # - +Date.parse('2026-01-01')..Date.today+ -> +start-date:>=2026-01-01,start-date:<=2026-05-21+
324
+ # - +Date.parse('2026-01-01')...Date.today+ -> +start-date:>=2026-01-01,start-date:<2026-05-21+ (exclusive end)
325
+ # - +..Date.today+ (beginless) / +Date.parse('2026-01-01')..+ (endless) drop the unbounded side
326
+ #
327
+ # Multiple field constraints AND together. Composes with the existing +priority_list:+ filter: both feed the
328
+ # same +issue_field_values+ query param.
329
+ #
330
+ # @raise [ArgumentError] when +priority_list: false+ is passed, or when +issue_type:+ is an Array (GitHub's
331
+ # REST +type+ param only accepts a single value)
332
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] when +issue_fields:+ is passed and
333
+ # +config.issue_fields_enabled+ is +false+
334
+ #
154
335
  # @param repo [Symbol, String, nil] defaults to config.default_repo
155
336
  # @param state [Symbol] :open, :closed, or :all
156
337
  # @param labels [Array<String>]
338
+ # @param issue_type [String, Symbol, nil] a single GitHub issue type name. Symbols resolve through
339
+ # +ISSUE_TYPE_NICKNAMES+ then +config.issue_types+ for org-specific renames.
340
+ # @param issue_fields [Hash{String,Symbol => Object,Range,nil}, nil] GitHub Issue Field equality / range
341
+ # filters. See description for the value shapes the gem accepts.
342
+ # @param priority_list [Boolean, nil] when +true+, restricts to issues whose +Priority List+ issue field is
343
+ # +Yes+ (server-side filter via the +issue_field_values+ query param). +false+ raises +ArgumentError+ -- GitHub
344
+ # has no negation qualifier. Silently dropped when +config.issue_fields_enabled+ is +false+.
157
345
  # @param page [Integer]
158
346
  # @param per_page [Integer]
159
347
  #
160
348
  # @return [Array<PlanMyStuff::Issue>]
161
349
  #
162
- def list(repo: nil, state: :open, labels: [], page: 1, per_page: 25)
350
+ def list(**)
351
+ list_page_info(**).issues
352
+ end
353
+
354
+ # Lists GitHub issues like +.list+, but returns a +PageInfo+ value object carrying the issues plus pagination
355
+ # metadata read from the response's +Link+ header in the same request. Use this over +.list+ when a caller needs
356
+ # to know whether more pages exist (e.g. to render "Next"/"Prev" controls) without an optimistic +page + 1+
357
+ # probe.
358
+ #
359
+ # Shares the entire parameter surface, filtering, and PR-rejection behavior of +.list+; see it for semantics.
360
+ #
361
+ # Note the PR-filter wart: +per_page+ caps GitHub's raw item count (issues + PRs), but PRs are stripped
362
+ # client-side afterward, so +page_info.issues.length+ may be smaller than +per_page+. +has_next?+ comes straight
363
+ # from the +Link+ header, so it reflects raw items too -- a page can report +has_next? == true+ while showing
364
+ # fewer than +per_page+ issues.
365
+ #
366
+ # @raise [ArgumentError] when +priority_list: false+ is passed, or when +issue_type:+ is an Array
367
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] when +issue_fields:+ is passed and
368
+ # +config.issue_fields_enabled+ is +false+
369
+ #
370
+ # @param repo [Symbol, String, nil] defaults to config.default_repo
371
+ # @param state [Symbol] :open, :closed, or :all
372
+ # @param labels [Array<String>]
373
+ # @param issue_type [String, Symbol, nil] a single GitHub issue type name
374
+ # @param issue_fields [Hash{String,Symbol => Object,Range,nil}, nil] GitHub Issue Field equality / range filters
375
+ # @param priority_list [Boolean, nil] when +true+, restricts to +Priority List+ +Yes+ issues
376
+ # @param page [Integer]
377
+ # @param per_page [Integer]
378
+ #
379
+ # @return [PlanMyStuff::Issue::PageInfo]
380
+ #
381
+ def list_page_info(
382
+ repo: nil,
383
+ state: :open,
384
+ labels: [],
385
+ issue_type: nil,
386
+ issue_fields: nil,
387
+ priority_list: nil,
388
+ page: 1,
389
+ per_page: 25
390
+ )
391
+ if priority_list == false
392
+ raise(ArgumentError, 'priority_list: false is not supported (no GitHub negation qualifier)')
393
+ end
394
+ if issue_fields.present? && !PlanMyStuff.configuration.issue_fields_enabled
395
+ raise(PlanMyStuff::IssueFieldsNotEnabledError)
396
+ end
397
+
163
398
  client = PlanMyStuff.client
164
- resolved_repo = client.resolve_repo(repo)
399
+ resolved_repo = client.resolve_repo!(repo)
165
400
 
166
- options = { state: state.to_s, page: page, per_page: per_page }
167
- options[:labels] = labels.join(',') if labels.any?
401
+ params = { state: state.to_s, page: page, per_page: per_page }
402
+ params[:labels] = labels.sort.join(',') if labels.present?
168
403
 
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
404
+ resolved_type = resolve_issue_types_filter(issue_type)
405
+ params[:type] = resolved_type if resolved_type.present?
172
406
 
173
- build(gi, repo: resolved_repo)
407
+ field_pairs = []
408
+ field_pairs.concat(build_issue_field_filter_pairs(issue_fields)) if issue_fields.present?
409
+ if priority_list && PlanMyStuff.configuration.issue_fields_enabled
410
+ field_pairs.concat(build_issue_field_filter_pairs('Priority List' => 'Yes'))
174
411
  end
412
+ params[:issue_field_values] = field_pairs.join(',') if field_pairs.present?
413
+
414
+ github_issues = client.rest(:list_issues, resolved_repo, **params)
415
+ rels = client.last_response&.rels || {}
416
+ filtered = github_issues.reject { |gi| gi.respond_to?(:pull_request) && gi.pull_request }
417
+
418
+ PageInfo.new(
419
+ issues: filtered.map { |gi| build(gi, repo: resolved_repo) },
420
+ page: page,
421
+ per_page: per_page,
422
+ has_next: rels[:next].present?,
423
+ has_prev: rels[:prev].present?,
424
+ )
175
425
  end
176
426
 
177
- # Adds user IDs to the visibility allowlist of an issue's metadata.
427
+ # Convenience shortcut for +list(priority_list: true, ...)+. See +.list+ for parameter semantics.
428
+ #
429
+ # @return [Array<PlanMyStuff::Issue>]
430
+ #
431
+ def priority_list(**)
432
+ list(**, priority_list: true)
433
+ end
434
+
435
+ # Counts GitHub issues matching the given filters without paginating full payloads.
436
+ #
437
+ # Uses GitHub's Search API (+search/issues+), which returns +total_count+ in a single
438
+ # request. The +is:issue+ qualifier excludes PRs server-side.
439
+ #
440
+ # Caveats:
441
+ # - The search index lags writes by up to ~1 minute, so freshly created/closed issues
442
+ # may not be reflected immediately.
443
+ # - The Search API has its own rate limit (30 req/min authenticated) separate from
444
+ # the core REST API.
445
+ #
446
+ # @raise [ArgumentError] when +priority_list: false+ is passed, or when +issue_type:+ is an Array
447
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] when +issue_fields:+ is passed and
448
+ # +config.issue_fields_enabled+ is +false+
178
449
  #
179
- # @param number [Integer]
180
450
  # @param repo [Symbol, String, nil] defaults to config.default_repo
181
- # @param user_ids [Array<Integer>]
451
+ # @param state [Symbol] :open, :closed, or :all
452
+ # @param labels [Array<String>]
453
+ # @param issue_type [String, Symbol, nil] a single GitHub issue type name. Symbols resolve through
454
+ # +ISSUE_TYPE_NICKNAMES+ then +config.issue_types+.
455
+ # @param issue_fields [Hash{String,Symbol => Object,Range,nil}, nil] GitHub Issue Field equality / range
456
+ # filters. See +.list+ for the value shapes the gem accepts. Each pair is emitted as a
457
+ # +field.<slug>:<value>+ Search qualifier and triggers +advanced_search=true+.
458
+ # @param priority_list [Boolean, nil] when +true+, restricts to issues whose +Priority List+ issue field is
459
+ # +Yes+ (server-side filter via the +field.priority-list:Yes+ Search qualifier). +false+ raises
460
+ # +ArgumentError+ -- GitHub has no negation qualifier. Silently dropped when
461
+ # +config.issue_fields_enabled+ is +false+.
462
+ #
463
+ # @return [Integer]
464
+ #
465
+ def count(repo: nil, state: :open, labels: [], issue_type: nil, issue_fields: nil, priority_list: nil)
466
+ if priority_list == false
467
+ raise(ArgumentError, 'priority_list: false is not supported (no GitHub negation qualifier)')
468
+ end
469
+ if issue_fields.present? && !PlanMyStuff.configuration.issue_fields_enabled
470
+ raise(PlanMyStuff::IssueFieldsNotEnabledError)
471
+ end
472
+
473
+ client = PlanMyStuff.client
474
+ resolved_repo = client.resolve_repo!(repo)
475
+
476
+ normalized_state = state.to_s
477
+ qualifiers = ["repo:#{resolved_repo}", 'is:issue']
478
+ qualifiers << "is:#{normalized_state}" unless normalized_state == 'all'
479
+ labels_to_use = Array.wrap(labels).sort
480
+ qualifiers += labels_to_use.map do |label|
481
+ "label:\"#{label}\""
482
+ end
483
+
484
+ resolved_type = resolve_issue_types_filter(issue_type)
485
+ qualifiers << "type:#{resolved_type}" if resolved_type.present?
486
+
487
+ field_pairs = []
488
+ field_pairs.concat(build_issue_field_filter_pairs(issue_fields)) if issue_fields.present?
489
+ if priority_list && PlanMyStuff.configuration.issue_fields_enabled
490
+ field_pairs.concat(build_issue_field_filter_pairs('Priority List' => 'Yes'))
491
+ end
492
+ qualifiers += field_pairs.map { |pair| "field.#{pair}" }
493
+
494
+ search_options = { per_page: 1 }
495
+ search_options[:advanced_search] = true if field_pairs.present?
496
+ client.rest(:search_issues, qualifiers.join(' '), **search_options).total_count
497
+ end
498
+
499
+ # Submits one or more pre-built payloads to GitHub's "Import Issues" preview endpoint
500
+ # (+POST /repos/:repo/import/issues+). One request per payload: the endpoint only accepts a single
501
+ # +{issue:, comments:}+ payload at a time.
182
502
  #
183
- # @return [Object] Octokit response
503
+ # Each payload hash MUST include a +:repo+ key (symbol, string, or +PlanMyStuff::Repo+) and the GitHub-shaped
504
+ # +:issue+ /+ :comments+ keys; +:repo+ is extracted before the POST. Payloads are passed through to GitHub
505
+ # unchanged otherwise - callers are responsible for shape, encoding, and any PlanMyStuff metadata they want to
506
+ # embed.
184
507
  #
185
- def add_viewers(number:, user_ids:, repo: nil)
186
- modify_allowlist(number: number, repo: repo) do |allowlist|
187
- allowlist | Array.wrap(user_ids)
508
+ # The endpoint is async: each response carries an +id+ and +url+ for polling via +Issue.check_import+.
509
+ #
510
+ # @raise [ArgumentError] when the import payload is missing :repo
511
+ #
512
+ # @param payloads [Array<Hash>, Hash]
513
+ #
514
+ # @return [Array<Hash>] one parsed status hash per input payload, in input order
515
+ #
516
+ def import!(payloads)
517
+ client = PlanMyStuff.import_client
518
+
519
+ Array.wrap(payloads).map do |payload|
520
+ repo = payload[:repo] || payload['repo'] || PlanMyStuff.configuration.default_repo
521
+ raise(ArgumentError, 'import payload must include :repo') if repo.blank?
522
+
523
+ body = payload.except(:repo, 'repo')
524
+ submit_import_request!(client, client.resolve_repo!(repo), body)
188
525
  end
189
526
  end
190
527
 
191
- # Removes user IDs from the visibility allowlist of an issue's metadata.
528
+ # Polls a previously-submitted import for its current status.
192
529
  #
193
- # @param number [Integer]
530
+ # @raise [PlanMyStuff::APIError] when the GitHub API call fails
531
+ #
532
+ # @param import_id [Integer] +id+ from the +Issue.import+ response
194
533
  # @param repo [Symbol, String, nil] defaults to config.default_repo
195
- # @param user_ids [Array<Integer>]
196
534
  #
197
- # @return [Object] Octokit response
535
+ # @return [Hash] parsed status response
536
+ #
537
+ def check_import!(import_id, repo: nil)
538
+ client = PlanMyStuff.import_client
539
+ resolved_repo = client.resolve_repo!(repo)
540
+
541
+ client.octokit.get(
542
+ "/repos/#{resolved_repo}/import/issues/#{import_id}",
543
+ accept: 'application/vnd.github.golden-comet-preview+json',
544
+ )
545
+ rescue Octokit::ClientError, Octokit::ServerError => e
546
+ raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
547
+ end
548
+
549
+ # @raise [ArgumentError] when +repo+ resolves to a Repo with no configured key (cannot reverse-resolve through
550
+ # +Repo.from_nickname!+, so the resulting token would not round-trip through +Issue.find+ / +from_param+)
551
+ #
552
+ # @param number [Integer]
553
+ # @param repo [String] full repo path, e.g. +"BrandsInsurance/Element"+
198
554
  #
199
- def remove_viewers(number:, user_ids:, repo: nil)
200
- modify_allowlist(number: number, repo: repo) do |allowlist|
201
- allowlist - Array.wrap(user_ids)
555
+ # @return [String]
556
+ #
557
+ def to_param(number, repo)
558
+ return if number.blank?
559
+ return if repo.blank?
560
+
561
+ repo_obj = PlanMyStuff::Repo.resolve!(repo)
562
+ if repo_obj.key.nil?
563
+ raise(
564
+ ArgumentError,
565
+ "Repo #{repo_obj.full_name.inspect} is not configured in config.repos; " \
566
+ 'cannot build reversible Issue#to_param token',
567
+ )
202
568
  end
569
+
570
+ "#{repo_obj.nickname}-#{number}"
571
+ end
572
+
573
+ # Parses an +Issue#to_param+ string of the form +"Nickname-1234"+ back into +[Repo, Integer]+. The repo is
574
+ # looked up via +PlanMyStuff::Repo.from_nickname!+, which scans +config.repos+ for the key whose
575
+ # +config.repo_nickname_for+ matches.
576
+ #
577
+ # @raise [ArgumentError] when +param+ does not match the +"Prefix-1234"+ shape or the prefix is not a known
578
+ # repo nickname
579
+ #
580
+ # @param param [String]
581
+ #
582
+ # @return [Array(PlanMyStuff::Repo, Integer)]
583
+ #
584
+ def from_param(param)
585
+ match = param.to_s.match(/\A(?<nickname>.+)-(?<number>\d+)\z/)
586
+ raise(ArgumentError, "Invalid issue param: #{param.inspect}") if match.nil?
587
+
588
+ [PlanMyStuff::Repo.from_nickname!(match[:nickname]), match[:number].to_i]
203
589
  end
204
590
 
205
591
  private
206
592
 
593
+ # Splits the +Issue.find+ first arg into +[number, resolved_repo_full_name]+. A nickname-id String like
594
+ # +"Rawr-1234"+ is decoded via +from_param+ (repo derived from the prefix; +repo:+ kwarg ignored). All other
595
+ # inputs (Integer, all-digit String) fall through to the existing +client.resolve_repo!+ path with the kwarg.
596
+ #
597
+ # @param id_or_number [Integer, String]
598
+ # @param repo [Symbol, String, PlanMyStuff::Repo, nil]
599
+ #
600
+ # @return [Array(Integer, String)]
601
+ #
602
+ def resolve_find_args(id_or_number, repo)
603
+ if id_or_number.is_a?(String) && !id_or_number.match?(/\A\d+\z/)
604
+ repo_obj, number = from_param(id_or_number)
605
+ [number, repo_obj.full_name]
606
+ else
607
+ [id_or_number.to_i, PlanMyStuff.client.resolve_repo!(repo)]
608
+ end
609
+ end
610
+
611
+ # Resolves an +issue_type:+ kwarg to the literal display name GitHub expects. Two stages: a Symbol is first
612
+ # looked up in the gem-side +ISSUE_TYPE_NICKNAMES+ to get a canonical name; then the canonical (or
613
+ # directly-provided String) name is passed through +config.issue_types+ for org-specific renames. Missing
614
+ # entries in +config.issue_types+ fall through unchanged.
615
+ #
616
+ # @raise [ArgumentError] if a Symbol isn't a known nickname, or +value+ is not a Symbol/String/nil
617
+ #
618
+ # @param value [Symbol, String, nil]
619
+ #
620
+ # @return [String, nil]
621
+ #
622
+ def resolve_issue_type!(value)
623
+ return if value.nil?
624
+
625
+ canonical =
626
+ case value
627
+ when String
628
+ begin
629
+ resolve_issue_type!(value.to_sym)
630
+ rescue ArgumentError
631
+ value
632
+ end
633
+ when Symbol
634
+ ISSUE_TYPE_NICKNAMES[value] || raise(
635
+ ArgumentError,
636
+ "Unknown issue_type nickname #{value.inspect}; known: #{ISSUE_TYPE_NICKNAMES.keys.inspect}",
637
+ )
638
+ else
639
+ raise(ArgumentError, "issue_type must be a String, Symbol, or nil; got #{value.class}")
640
+ end
641
+
642
+ PlanMyStuff.configuration.issue_types[canonical] || canonical
643
+ end
644
+
645
+ # Resolves an +issue_type:+ filter kwarg (used by +.list+ / +.count+) into a single canonical type name.
646
+ # Accepts a scalar (String / Symbol); +nil+ returns +nil+ so callers can skip the filter entirely. Runs
647
+ # through +resolve_issue_type!+ so symbol nicknames and +config.issue_types+ overrides apply consistently
648
+ # with +create!+ / +update!+. Arrays raise +ArgumentError+ -- GitHub's REST +type+ param and Search
649
+ # +type:+ qualifier each accept a single value at a time.
650
+ #
651
+ # @raise [ArgumentError] when +value+ is an Array
652
+ #
653
+ # @param value [String, Symbol, nil]
654
+ #
655
+ # @return [String, nil]
656
+ #
657
+ def resolve_issue_types_filter(value)
658
+ return if value.nil?
659
+ if value.is_a?(Array)
660
+ raise(ArgumentError, 'issue_type: must be a single String / Symbol; GitHub does not accept multiple types')
661
+ end
662
+
663
+ resolve_issue_type!(value)
664
+ end
665
+
666
+ # Slugifies a field name into the kebab-case form GitHub expects in +issue_field_values+ and the Search API's
667
+ # +field.<slug>:+ qualifier (e.g. +"Priority List"+ / +:priority_list+ -> +"priority-list"+). Lowercases and
668
+ # collapses runs of whitespace or underscores into a single hyphen.
669
+ #
670
+ # @param name [String, Symbol]
671
+ #
672
+ # @return [String]
673
+ #
674
+ def field_filter_slug(name)
675
+ name.to_s.downcase.gsub(/[\s_]+/, '-')
676
+ end
677
+
678
+ # Coerces an issue-field filter value to the literal string GitHub expects in the query (Date / Time -> ISO
679
+ # 8601, Numeric / scalar -> +to_s+). Range bounds are handled separately by
680
+ # +build_issue_field_filter_pairs+.
681
+ #
682
+ # @param value [Object]
683
+ #
684
+ # @return [String]
685
+ #
686
+ def format_field_filter_value(value)
687
+ case value
688
+ when Date, Time then value.iso8601
689
+ else value.to_s
690
+ end
691
+ end
692
+
693
+ # Expands an +issue_fields:+ kwarg hash into the flat +Array<String>+ of +slug:value+ pairs that both REST
694
+ # (+issue_field_values=...+) and Search (+field.slug:value...+) consume.
695
+ #
696
+ # Scalars become a single equality pair. +Range+ values expand into one or two comparison pairs:
697
+ # +>=+/+<=+ for inclusive bounds, +<+ for an exclusive end (+...+). Beginless / endless ranges emit only
698
+ # the bounded side. +nil+ values are skipped.
699
+ #
700
+ # @param hash [Hash{String,Symbol => Object,Range,nil}]
701
+ #
702
+ # @return [Array<String>]
703
+ #
704
+ def build_issue_field_filter_pairs(hash)
705
+ hash.flat_map do |name, value|
706
+ slug = field_filter_slug(PlanMyStuff::IssueFieldTranslation.consumer_field_name(name))
707
+ case value
708
+ when nil then []
709
+ when Range then range_field_filter_pairs(slug, value)
710
+ else
711
+ consumer_value = PlanMyStuff::IssueFieldTranslation.consumer_value(name, value)
712
+ ["#{slug}:#{format_field_filter_value(consumer_value)}"]
713
+ end
714
+ end
715
+ end
716
+
717
+ # Expands a +Range+ value into the +slug:>=begin+ / +slug:<=end+ (or +<end+ for +...+) pair(s) GitHub expects.
718
+ #
719
+ # @param slug [String]
720
+ # @param range [Range]
721
+ #
722
+ # @return [Array<String>]
723
+ #
724
+ def range_field_filter_pairs(slug, range)
725
+ pairs = []
726
+ pairs << "#{slug}:>=#{format_field_filter_value(range.begin)}" if range.begin.present?
727
+ if range.end.present?
728
+ end_op = range.exclude_end? ? '<' : '<='
729
+ pairs << "#{slug}:#{end_op}#{format_field_filter_value(range.end)}"
730
+ end
731
+ pairs
732
+ end
733
+
734
+ # @raise [PlanMyStuff::APIError] when the GitHub API call fails
735
+ #
736
+ # @return [Hash]
737
+ #
738
+ def submit_import_request!(client, resolved_repo, payload)
739
+ client.octokit.post(
740
+ "/repos/#{resolved_repo}/import/issues",
741
+ payload.merge(accept: 'application/vnd.github.golden-comet-preview+json'),
742
+ )
743
+ rescue Octokit::ClientError, Octokit::ServerError => e
744
+ raise(PlanMyStuff::APIError.new(e.message, status: e.respond_to?(:response_status) ? e.response_status : nil))
745
+ end
746
+
747
+ # Builds the visible body string written to GitHub for an issue: a markdown link to the consuming-app
748
+ # per-issue URL (in +Issue#to_param+ form, e.g. +"/issues/Rawr-1234"+), labelled with the GitHub
749
+ # +Org/Repo#number+. Returns +""+ when either +config.issues_url_prefix+ or +number+ is missing.
750
+ #
751
+ # @param number [Integer]
752
+ # @param repo [String] full repo path, e.g. +"BrandsInsurance/Element"+
753
+ #
754
+ # @return [String]
755
+ #
756
+ def visible_body_for(number, repo)
757
+ prefix = PlanMyStuff.configuration.issues_url_prefix
758
+ return '' if prefix.blank? || number.blank?
759
+
760
+ to_par = to_param(number, repo)
761
+ url = "#{prefix.to_s.chomp('/')}/#{to_par}"
762
+ "[#{repo}##{number}](#{url})"
763
+ end
764
+
207
765
  # Hydrates an Issue from a GitHub API response.
208
766
  #
209
767
  # @param github_issue [Object] Octokit issue response
@@ -217,41 +775,21 @@ module PlanMyStuff
217
775
  issue
218
776
  end
219
777
 
778
+ # @raise [ArgumentError] when add_to_project is true but no default_project_number is configured
779
+ #
220
780
  # @return [Integer]
221
- def resolve_project_number(add_to_project)
781
+ #
782
+ def resolve_project_number!(add_to_project)
222
783
  return add_to_project unless add_to_project == true
223
784
 
224
785
  PlanMyStuff.configuration.default_project_number ||
225
786
  raise(ArgumentError, 'add_to_project: true requires config.default_project_number to be set')
226
787
  end
227
788
 
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]
789
+ # Finds the first PMS comment on an issue and updates its body content, preserving the comment header and
790
+ # metadata.
233
791
  #
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.
792
+ # @raise [PlanMyStuff::Error] when the issue has no body comment
255
793
  #
256
794
  # @param number [Integer] issue number
257
795
  # @param resolved_repo [String] resolved repo path
@@ -259,73 +797,169 @@ module PlanMyStuff
259
797
  #
260
798
  # @return [void]
261
799
  #
262
- def update_body_comment(number, resolved_repo, new_body)
800
+ def update_body_comment!(number, resolved_repo, new_body)
263
801
  issue = find(number, repo: resolved_repo)
264
802
  body_comment = issue.body_comment
265
803
  raise(PlanMyStuff::Error, "No body comment found on issue ##{number}") if body_comment.nil?
266
804
 
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)
805
+ body_comment.update!(body: new_body)
277
806
  end
278
807
  end
279
808
 
280
809
  def initialize(**attrs)
281
- @number = attrs.delete(:number)
282
- @raw_body = nil
283
- @metadata = IssueMetadata.new
810
+ @body_dirty = false
284
811
  super
285
- @labels ||= []
286
812
  end
287
813
 
288
- # Persists the issue. Creates if new, updates if persisted.
814
+ # @param value [PlanMyStuff::Repo, Symbol, String, nil]
815
+ def repo=(value)
816
+ super(value.present? ? PlanMyStuff::Repo.resolve!(value) : nil)
817
+ end
818
+
819
+ # Assigning a new body marks the instance dirty so the next +save!+ rewrites the backing PMS body comment.
820
+ # Unsaved assignments are reflected by +#body+ until persisted or reloaded.
821
+ #
822
+ # @param value [String]
289
823
  #
290
- # @raise [PlanMyStuff::StaleObjectError] on update if stale
824
+ # @return [String]
825
+ #
826
+ def body=(value)
827
+ super
828
+ @body_dirty = true
829
+ end
830
+
831
+ # Single-segment URL token combining repo nickname and issue number, used by Rails route helpers
832
+ # (+youtrack_issue_path(@issue)+ -> +"/issues/Rawr-1234"+). Returns +nil+ for new records or when +number+ or
833
+ # +repo+ is unset; +Issue.from_param+ parses the same shape back into +[Repo, Integer]+.
834
+ #
835
+ # @return [String, nil]
836
+ #
837
+ def to_param
838
+ return if new_record?
839
+
840
+ self.class.to_param(number, repo)
841
+ end
842
+
843
+ # @return [String, nil] per-issue URL in the consuming app (+config.issues_url_prefix+ + +"/"+ + +to_param+, or
844
+ # +nil+ when prefix, number, or repo is missing). Also rendered as the destination of the markdown link in
845
+ # the GitHub issue body.
846
+ def user_link
847
+ prefix = PlanMyStuff.configuration.issues_url_prefix
848
+ return if prefix.blank?
849
+
850
+ to_par = to_param
851
+ return if to_par.blank?
852
+
853
+ "#{prefix.to_s.chomp('/')}/#{to_par}"
854
+ end
855
+
856
+ # Tags the issue with the configured +archived_label+, removes it from every Projects V2 board it belongs to,
857
+ # locks its conversation on GitHub, and stamps +metadata.archived_at+. Emits +issue_archived.plan_my_stuff+ on
858
+ # success.
859
+ #
860
+ # No-op (no network calls, no event) when the issue is already archived (either +metadata.archived_at+ is set or
861
+ # the archived label is already on the issue).
862
+ #
863
+ # @param now [Time] clock reference for +metadata.archived_at+
291
864
  #
292
865
  # @return [self]
293
866
  #
294
- def save!
867
+ def archive!(now: Time.now.utc)
868
+ label = PlanMyStuff.configuration.archived_label
869
+ return self unless state == 'closed'
870
+
871
+ return self if metadata.archived_at.present?
872
+ return self if labels.include?(label)
873
+
874
+ self.class.update!(
875
+ number: number,
876
+ repo: repo,
877
+ metadata: { archived_at: PlanMyStuff.format_time(now) },
878
+ )
879
+
880
+ PlanMyStuff::Label.ensure!(repo: repo, name: label)
881
+ PlanMyStuff::Label.add!(issue: self, labels: [label])
882
+
883
+ remove_from_all_projects!
884
+
885
+ PlanMyStuff.client.rest(:lock_issue, repo.full_name, number)
886
+
887
+ reload
888
+
889
+ PlanMyStuff::Notifications.instrument(
890
+ 'issue_archived',
891
+ self,
892
+ reason: :aged_closed,
893
+ )
894
+ self
895
+ end
896
+
897
+ # Persists the issue. Creates if new, otherwise performs a full write: serializes +@metadata+ into the GitHub
898
+ # issue body and PATCHes title/state/labels. When +#body=+ has been called since the last load, also rewrites
899
+ # the PMS body comment. Always reloads afterwards.
900
+ #
901
+ # @return [self]
902
+ #
903
+ def save!(user: nil, skip_notification: false)
295
904
  if new_record?
296
905
  created = self.class.create!(
297
906
  title: title,
298
907
  body: body,
299
908
  repo: repo,
300
909
  labels: labels || [],
910
+ user: user || metadata.created_by,
911
+ metadata: metadata.custom_fields.to_h,
912
+ visibility: metadata.visibility,
913
+ visibility_allowlist: Array.wrap(metadata.visibility_allowlist),
914
+ issue_type: issue_type,
915
+ issue_fields: @pending_issue_fields,
301
916
  )
302
917
  hydrate_from_issue(created)
303
918
  else
304
- update!(body: body, state: state, labels: labels)
919
+ captured_changes = changes.dup
920
+ persist_update!
921
+ instrument_update(captured_changes, user) unless skip_notification
305
922
  end
306
923
 
924
+ @pending_issue_fields = nil
307
925
  self
308
926
  end
309
927
 
310
- # Updates this issue on GitHub. Raises StaleObjectError if the remote
311
- # has been modified since this instance was loaded.
928
+ # Applies +attrs+ to this instance in-memory then calls +save!+. Supports +title:+, +body:+, +state:+,
929
+ # +labels:+, +assignees:+, and +metadata:+. The +metadata:+ kwarg is a hash whose keys are merged into the
930
+ # existing +metadata+ (top-level attributes assigned directly; +:custom_fields+ merged key-by-key).
312
931
  #
313
- # @param attrs [Hash] attributes to update (title:, body:, state:, labels:, metadata:)
314
- #
315
- # @raise [PlanMyStuff::StaleObjectError] if remote updated_at differs from local
932
+ # @param user [Object, nil] actor for notification events
316
933
  #
317
934
  # @return [self]
318
935
  #
319
- def update!(**attrs)
320
- raise_if_stale!
936
+ def update!(user: nil, skip_notification: false, **attrs)
937
+ apply_update_attrs(attrs)
938
+ save!(user: user, skip_notification: skip_notification)
939
+ end
940
+
941
+ # Stamps +metadata.responded_at+ on the first support-user engagement with this issue. Centralizes the guards so
942
+ # every engagement path (first support comment, +Pipeline.take!+, self-assign webhook) can funnel through one
943
+ # method. No-ops unless +user+ resolves to a support user on a PMS issue that hasn't been responded to yet.
944
+ #
945
+ # @param user [Object, nil] actor engaging with the issue (resolved via +PlanMyStuff::UserResolver+)
946
+ #
947
+ # @return [void]
948
+ #
949
+ def mark_responded!(user)
950
+ resolved = PlanMyStuff::UserResolver.resolve(user)
951
+ return if resolved.blank?
952
+
953
+ return unless PlanMyStuff::UserResolver.support?(resolved)
954
+ return unless pms_issue?
955
+
956
+ return if metadata.responded?
321
957
 
322
958
  self.class.update!(
323
959
  number: number,
324
960
  repo: repo,
325
- **attrs,
961
+ metadata: { responded_at: PlanMyStuff.format_time(Time.now.utc) },
326
962
  )
327
-
328
- reload
329
963
  end
330
964
 
331
965
  # Re-fetches this issue from GitHub and updates all local attributes.
@@ -346,6 +980,22 @@ module PlanMyStuff
346
980
  @comments ||= load_comments
347
981
  end
348
982
 
983
+ # GitHub web URL for this issue, for escape-hatch "View on GitHub" links.
984
+ #
985
+ # @return [String, nil]
986
+ #
987
+ def html_url
988
+ safe_read_field(github_response, :html_url)
989
+ end
990
+
991
+ # GitHub assignees for this issue, by login.
992
+ #
993
+ # @return [Array<String>]
994
+ #
995
+ def assignees
996
+ extract_assignee_logins(github_response)
997
+ end
998
+
349
999
  # @return [Boolean]
350
1000
  def pms_issue?
351
1001
  metadata.schema_version.present?
@@ -364,36 +1014,118 @@ module PlanMyStuff
364
1014
  pms_comments.find { |c| c.metadata.issue_body? }
365
1015
  end
366
1016
 
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.
1017
+ # Returns the issue body content. For PMS issues, this is the body from the body comment (stripped of its
1018
+ # header). Falls back to the parsed issue body for non-PMS issues.
370
1019
  #
371
1020
  # @return [String, nil]
372
1021
  #
373
1022
  def body
374
- return @body if new_record?
375
-
376
- return @body unless pms_issue?
1023
+ return super if new_record?
1024
+ return super if @body_dirty
1025
+ return super unless pms_issue?
377
1026
 
378
1027
  bc = body_comment
379
1028
  return bc.body_without_header if bc.present?
380
1029
 
381
- @body
1030
+ super
1031
+ end
1032
+
1033
+ # GitHub GraphQL node ID (required for native sub-issue mutations). Read from the hydrated REST response.
1034
+ #
1035
+ # @return [String, nil]
1036
+ #
1037
+ def github_node_id
1038
+ safe_read_field(github_response, :node_id)
1039
+ end
1040
+
1041
+ # GitHub database ID (required for the REST issue-dependency API, which takes integer issue_id rather than issue
1042
+ # number).
1043
+ #
1044
+ # @return [Integer, nil]
1045
+ #
1046
+ def github_id
1047
+ safe_read_field(github_response, :id)
382
1048
  end
383
1049
 
384
- # Delegates visibility check to metadata.
385
- # Non-PMS issues are always visible.
1050
+ # Returns a hash-like view of GitHub Issue Field values currently set on this issue. Reads on first access and
1051
+ # memoizes; +set_issue_fields!+ invalidates the cache. Returns an empty set without making a request when
1052
+ # +config.issue_fields_enabled+ is +false+.
386
1053
  #
387
- # @param user [Object, Integer] user object or user_id
1054
+ # @return [PlanMyStuff::IssueFieldValueSet]
388
1055
  #
1056
+ def issue_fields
1057
+ @issue_fields ||= load_issue_fields!
1058
+ end
1059
+
389
1060
  # @return [Boolean]
1061
+ def priority_list?
1062
+ issue_fields['Priority List'] == 'Yes'
1063
+ end
1064
+
1065
+ # @return [Integer, nil]
1066
+ def priority_list_priority
1067
+ issue_fields['Priority List Priority']
1068
+ end
1069
+
1070
+ # Adds this issue to the Priority List at the given priority (or re-prioritizes if already listed). Sets
1071
+ # +Priority List+ and +Priority List Priority+ together in a single +setIssueFieldValue+ mutation so the two
1072
+ # fields never drift out of sync.
390
1073
  #
391
- def visible_to?(user)
392
- if pms_issue?
393
- metadata.visible_to?(user)
394
- else
395
- UserResolver.support?(UserResolver.resolve(user))
1074
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] when +config.issue_fields_enabled+ is +false+
1075
+ #
1076
+ # @param priority [Integer]
1077
+ #
1078
+ # @return [self]
1079
+ #
1080
+ def add_to_priority_list!(priority:)
1081
+ raise(ArgumentError, 'Priority must be an integer') unless priority.is_a?(Integer)
1082
+
1083
+ set_issue_fields!('Priority List' => 'Yes', 'Priority List Priority' => priority)
1084
+ end
1085
+
1086
+ alias update_priority_list_priority! add_to_priority_list!
1087
+
1088
+ # Removes this issue from the Priority List. Clears both +Priority List+ and +Priority List Priority+ in a single
1089
+ # +setIssueFieldValue+ mutation so the two fields never drift out of sync.
1090
+ #
1091
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] when +config.issue_fields_enabled+ is +false+
1092
+ #
1093
+ # @return [self]
1094
+ #
1095
+ def remove_from_priority_list!
1096
+ set_issue_fields!('Priority List' => nil, 'Priority List Priority' => nil)
1097
+ end
1098
+
1099
+ # Bulk-updates GitHub Issue Field values in a single +setIssueFieldValue+ mutation. Each key is the field display
1100
+ # name; values are coerced to the right input fragment based on the field's type. Passing +nil+ as a value clears
1101
+ # that field.
1102
+ #
1103
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] when +config.issue_fields_enabled+ is +false+
1104
+ # @raise [PlanMyStuff::Error] when a referenced field name does not exist on the org
1105
+ #
1106
+ # @param updates [Hash{String,Symbol => Object,nil}]
1107
+ #
1108
+ # @return [self]
1109
+ #
1110
+ def set_issue_fields!(updates)
1111
+ raise(PlanMyStuff::IssueFieldsNotEnabledError) unless PlanMyStuff.configuration.issue_fields_enabled
1112
+
1113
+ fields_by_name = PlanMyStuff::IssueField.list(org: repo.organization).index_by { |field| field.name.downcase }
1114
+ inputs = updates.map do |name, value|
1115
+ build_issue_field_input(
1116
+ fields_by_name,
1117
+ PlanMyStuff::IssueFieldTranslation.consumer_field_name(name),
1118
+ PlanMyStuff::IssueFieldTranslation.consumer_value(name, value),
1119
+ )
396
1120
  end
1121
+
1122
+ PlanMyStuff.client.graphql(
1123
+ PlanMyStuff::GraphQL::Queries::SET_ISSUE_FIELD_VALUES,
1124
+ variables: { issueId: github_node_id, issueFields: inputs },
1125
+ )
1126
+
1127
+ @issue_fields = nil
1128
+ self
397
1129
  end
398
1130
 
399
1131
  private
@@ -406,18 +1138,27 @@ module PlanMyStuff
406
1138
  # @return [void]
407
1139
  #
408
1140
  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
1141
+ @github_response = github_issue
1142
+ self.number = read_field(github_issue, :number)
1143
+ self.title = read_field(github_issue, :title)
1144
+ self.state = read_field(github_issue, :state)
1145
+ self.raw_body = read_field(github_issue, :body) || ''
1146
+ self.updated_at = parse_github_time(safe_read_field(github_issue, :updated_at))
1147
+ self.created_at = parse_github_time(safe_read_field(github_issue, :created_at))
1148
+ self.closed_at = parse_github_time(safe_read_field(github_issue, :closed_at))
1149
+ self.locked = safe_read_field(github_issue, :locked) || false
1150
+ self.labels = extract_labels(github_issue)
1151
+ self.issue_type = extract_issue_type(github_issue)
1152
+ self.repo = repo
1153
+
1154
+ parsed = PlanMyStuff::MetadataParser.parse(raw_body)
1155
+ self.metadata = PlanMyStuff::IssueMetadata.from_hash(parsed[:metadata])
1156
+ self.body = parsed[:body]
1157
+ @body_dirty = false
1158
+ persisted!
420
1159
  @comments = nil
1160
+ @issue_fields = nil
1161
+ invalidate_links_cache!
421
1162
  end
422
1163
 
423
1164
  # Copies attributes from another Issue instance into self.
@@ -427,20 +1168,110 @@ module PlanMyStuff
427
1168
  # @return [void]
428
1169
  #
429
1170
  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
1171
+ @github_response = other.github_response
1172
+ self.number = other.number
1173
+ self.title = other.title
1174
+ self.state = other.state
1175
+ self.body = other.attributes['body']
1176
+ @body_dirty = false
1177
+ self.raw_body = other.raw_body
1178
+ self.created_at = other.created_at
1179
+ self.updated_at = other.updated_at
1180
+ self.closed_at = other.closed_at
1181
+ self.locked = other.locked
1182
+ self.labels = other.labels
1183
+ self.issue_type = other.issue_type
1184
+ self.repo = other.repo
1185
+ self.metadata = other.metadata
1186
+ persisted!
439
1187
  @comments = nil
1188
+ @issue_fields = nil
1189
+ invalidate_links_cache!
1190
+ end
1191
+
1192
+ # Fires the appropriate notification event for an update: +issue.closed+ or +issue.reopened+ on a state
1193
+ # transition, otherwise +issue.updated+ with the captured dirty-tracking diff.
1194
+ #
1195
+ # @param captured [Hash] +ActiveModel::Dirty+ changes captured before +persist_update!+
1196
+ # @param user [Object, nil]
1197
+ #
1198
+ # @return [void]
1199
+ #
1200
+ def instrument_update(captured, user)
1201
+ case captured['state']
1202
+ when %w[open closed]
1203
+ PlanMyStuff::Notifications.instrument('issue_closed', self, user: user)
1204
+ when %w[closed open]
1205
+ PlanMyStuff::Notifications.instrument('issue_reopened', self, user: user)
1206
+ else
1207
+ PlanMyStuff::Notifications.instrument('issue_updated', self, user: user, changes: captured)
1208
+ end
1209
+ end
1210
+
1211
+ # Full-write persistence path for an already-persisted issue. Delegates to +Issue.update!+ passing the full
1212
+ # in-memory state (title/state/labels plus the current +metadata+ object so the class method serializes it
1213
+ # authoritatively). Only passes +body:+ when +@body_dirty+, so the PMS body comment is rewritten exactly when
1214
+ # +#body=+ has been called since load.
1215
+ #
1216
+ # @return [void]
1217
+ #
1218
+ def persist_update!
1219
+ raise_if_stale!
1220
+
1221
+ attrs = {
1222
+ number: number,
1223
+ repo: repo,
1224
+ title: title,
1225
+ state: state,
1226
+ labels: labels || [],
1227
+ metadata: metadata,
1228
+ }
1229
+ attrs[:body] = body if @body_dirty
1230
+ attrs[:assignees] = @pending_assignees unless @pending_assignees.nil?
1231
+ attrs[:issue_fields] = @pending_issue_fields if @pending_issue_fields.present?
1232
+ attrs[:issue_type] = issue_type if issue_type_changed?
1233
+
1234
+ clear_waiting_state_on_close(attrs)
1235
+ clear_inactivity_state_on_reopen(attrs)
1236
+
1237
+ self.class.update!(**attrs)
1238
+
1239
+ @body_dirty = false
1240
+ @pending_assignees = nil
1241
+ reload
1242
+ end
1243
+
1244
+ # Applies in-memory updates from an +update!+ kwargs hash. Top-level scalars go through their setters so
1245
+ # +@body_dirty+ and friends stay in sync; +metadata:+ is merged into +@metadata+ (top-level attrs assigned
1246
+ # directly, custom_fields merged key-by-key).
1247
+ #
1248
+ # @return [void]
1249
+ #
1250
+ def apply_update_attrs(attrs)
1251
+ self.title = attrs[:title] if attrs.key?(:title)
1252
+ self.state = attrs[:state].to_s if attrs.key?(:state)
1253
+ self.labels = attrs[:labels] if attrs.key?(:labels)
1254
+ self.body = attrs[:body] if attrs.key?(:body)
1255
+ self.issue_type = attrs[:issue_type] if attrs.key?(:issue_type)
1256
+ @pending_assignees = attrs[:assignees] if attrs.key?(:assignees)
1257
+ @pending_issue_fields = attrs[:issue_fields] if attrs.key?(:issue_fields)
1258
+ apply_metadata_attrs(attrs[:metadata]) if attrs.key?(:metadata)
1259
+ end
1260
+
1261
+ # @return [void]
1262
+ def apply_metadata_attrs(md_hash)
1263
+ return if md_hash.nil?
1264
+
1265
+ md_hash.each do |key, value|
1266
+ if key == :custom_fields
1267
+ value.each { |k, v| metadata.custom_fields[k] = v }
1268
+ elsif metadata.respond_to?("#{key}=")
1269
+ metadata.public_send("#{key}=", value)
1270
+ end
1271
+ end
440
1272
  end
441
1273
 
442
- # Raises StaleObjectError if the remote issue has been modified
443
- # since this instance was loaded.
1274
+ # Raises StaleObjectError if the remote issue has been modified since this instance was loaded.
444
1275
  #
445
1276
  # @raise [PlanMyStuff::StaleObjectError]
446
1277
  #
@@ -448,20 +1279,22 @@ module PlanMyStuff
448
1279
  #
449
1280
  def raise_if_stale!
450
1281
  return if new_record?
451
- return if metadata.updated_at.nil?
1282
+ return if updated_at.nil?
452
1283
 
453
1284
  remote = self.class.find(number, repo: repo)
454
- remote_time = remote.metadata.updated_at
455
- local_time = metadata.updated_at
1285
+ remote_time = remote.updated_at
1286
+ local_time = updated_at
456
1287
 
457
1288
  return if remote_time.nil?
458
1289
  return if local_time && remote_time.to_i == local_time.to_i
459
1290
 
460
- raise(StaleObjectError.new(
461
- "Issue ##{number} has been modified remotely",
462
- local_updated_at: local_time,
463
- remote_updated_at: remote_time,
464
- ))
1291
+ raise(
1292
+ PlanMyStuff::StaleObjectError.new(
1293
+ "Issue ##{number} has been modified remotely",
1294
+ local_updated_at: local_time,
1295
+ remote_updated_at: remote_time,
1296
+ ),
1297
+ )
465
1298
  end
466
1299
 
467
1300
  # @return [Array<String>]
@@ -470,6 +1303,18 @@ module PlanMyStuff
470
1303
  raw.map { |label| label_name(label) }
471
1304
  end
472
1305
 
1306
+ # Reads the +type.name+ from a REST issue payload. GitHub returns +"type"+ as a nested object (or +nil+) on
1307
+ # every issue response, so we descend into it for the human-readable name.
1308
+ #
1309
+ # @return [String, nil]
1310
+ #
1311
+ def extract_issue_type(github_issue)
1312
+ raw = safe_read_field(github_issue, :type)
1313
+ return if raw.nil?
1314
+
1315
+ safe_read_field(raw, :name)
1316
+ end
1317
+
473
1318
  # @return [String]
474
1319
  def label_name(label)
475
1320
  return label.name if label.respond_to?(:name)
@@ -480,7 +1325,93 @@ module PlanMyStuff
480
1325
 
481
1326
  # @return [Array<PlanMyStuff::Comment>]
482
1327
  def load_comments
483
- Comment.list(issue: self)
1328
+ PlanMyStuff::Comment.list(issue: self)
1329
+ end
1330
+
1331
+ # Walks every Projects V2 board this issue sits on and deletes the corresponding item. Paginates via
1332
+ # +LIST_ISSUE_PROJECT_ITEMS+ with a safety cap to avoid runaway loops. Delete failures propagate.
1333
+ #
1334
+ # @return [void]
1335
+ #
1336
+ def remove_from_all_projects!
1337
+ client = PlanMyStuff.client
1338
+ owner = repo.organization
1339
+ repo_name = repo.name
1340
+ cursor = nil
1341
+
1342
+ 10.times do
1343
+ data = client.graphql(
1344
+ PlanMyStuff::GraphQL::Queries::LIST_ISSUE_PROJECT_ITEMS,
1345
+ variables: { owner: owner, repo: repo_name, number: number, cursor: cursor },
1346
+ )
1347
+
1348
+ connection = data.dig(:repository, :issue, :projectItems) || {}
1349
+ nodes = Array.wrap(connection[:nodes])
1350
+
1351
+ nodes.each do |node|
1352
+ PlanMyStuff::ProjectItem.delete_item!(
1353
+ item_id: node[:id],
1354
+ project_number: node.dig(:project, :number),
1355
+ )
1356
+ end
1357
+
1358
+ page_info = connection[:pageInfo] || {}
1359
+ break unless page_info[:hasNextPage]
1360
+
1361
+ cursor = page_info[:endCursor]
1362
+ end
1363
+ end
1364
+
1365
+ # @raise [PlanMyStuff::Error]
1366
+ # @return [Integer]
1367
+ #
1368
+ def require_github_id!
1369
+ id = github_id
1370
+ raise(PlanMyStuff::Error, "Issue ##{number} has no database id; cannot run native REST mutation") if id.nil?
1371
+
1372
+ id
1373
+ end
1374
+
1375
+ # @return [PlanMyStuff::IssueFieldValueSet]
1376
+ def load_issue_fields!
1377
+ return PlanMyStuff::IssueFieldValueSet.new({}) unless PlanMyStuff.configuration.issue_fields_enabled
1378
+
1379
+ data = PlanMyStuff.client.graphql(
1380
+ PlanMyStuff::GraphQL::Queries::READ_ISSUE_FIELD_VALUES,
1381
+ variables: { owner: repo.organization, name: repo.name, number: number },
1382
+ )
1383
+ nodes = data.dig(:repository, :issue, :issueFieldValues, :nodes)
1384
+ PlanMyStuff::IssueFieldValueSet.from_graphql(nodes)
1385
+ end
1386
+
1387
+ # Builds one element of the +issueFields+ argument to the +setIssueFieldValue+ mutation. Looks up the field
1388
+ # definition in the provided hash to pick the right input fragment and (for single-select) resolve the option ID.
1389
+ #
1390
+ # @raise [PlanMyStuff::Error] if the field name is unknown on the org
1391
+ #
1392
+ # @param fields_by_name [Hash{String => PlanMyStuff::IssueField}] fields keyed by downcased display name
1393
+ # @param name [String, Symbol]
1394
+ # @param value [Object, nil]
1395
+ #
1396
+ # @return [Hash]
1397
+ #
1398
+ def build_issue_field_input(fields_by_name, name, value)
1399
+ field = fields_by_name[name.to_s.downcase]
1400
+ raise(PlanMyStuff::Error, "Unknown Issue Field #{name.inspect}") if field.nil?
1401
+
1402
+ return { fieldId: field.id, delete: true } if value.nil?
1403
+
1404
+ case field.type
1405
+ when :single_select then { fieldId: field.id, singleSelectOptionId: field.option_id_for!(value) }
1406
+ when :date then { fieldId: field.id, dateValue: value.to_date.iso8601 }
1407
+ when :number
1408
+ unless value.is_a?(Numeric)
1409
+ raise(PlanMyStuff::Error, "Issue Field #{name.inspect} expects Numeric, got #{value.inspect}")
1410
+ end
1411
+
1412
+ { fieldId: field.id, numberValue: value.to_f }
1413
+ when :text then { fieldId: field.id, textValue: value.to_s }
1414
+ end
484
1415
  end
485
1416
  end
486
1417
  end