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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +595 -0
- data/CONFIGURATION.md +487 -0
- data/README.md +612 -88
- data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
- data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
- data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
- data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
- data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
- data/app/jobs/plan_my_stuff/application_job.rb +8 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
- data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
- data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
- data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
- data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
- data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
- data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
- data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
- data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
- data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +43 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
- data/lib/plan_my_stuff/application_record.rb +158 -1
- data/lib/plan_my_stuff/approval.rb +88 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +12 -0
- data/lib/plan_my_stuff/attachment.rb +83 -0
- data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
- data/lib/plan_my_stuff/base_metadata.rb +25 -28
- data/lib/plan_my_stuff/base_project.rb +502 -0
- data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
- data/lib/plan_my_stuff/base_project_item.rb +588 -0
- data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
- data/lib/plan_my_stuff/cache.rb +197 -0
- data/lib/plan_my_stuff/client.rb +139 -64
- data/lib/plan_my_stuff/comment.rb +225 -100
- data/lib/plan_my_stuff/comment_metadata.rb +68 -5
- data/lib/plan_my_stuff/configuration.rb +459 -28
- data/lib/plan_my_stuff/custom_fields.rb +96 -12
- data/lib/plan_my_stuff/engine.rb +14 -2
- data/lib/plan_my_stuff/errors.rb +65 -5
- data/lib/plan_my_stuff/graphql/queries.rb +454 -0
- data/lib/plan_my_stuff/issue.rb +1097 -166
- data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
- data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
- data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
- data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
- data/lib/plan_my_stuff/issue_field.rb +126 -0
- data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
- data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
- data/lib/plan_my_stuff/issue_metadata.rb +132 -21
- data/lib/plan_my_stuff/label.rb +100 -13
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/markdown.rb +13 -7
- data/lib/plan_my_stuff/metadata_parser.rb +51 -12
- data/lib/plan_my_stuff/notifications.rb +148 -0
- data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +40 -0
- data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
- data/lib/plan_my_stuff/pipeline.rb +310 -0
- data/lib/plan_my_stuff/project.rb +63 -465
- data/lib/plan_my_stuff/project_item.rb +3 -409
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +47 -0
- data/lib/plan_my_stuff/reminders/closer.rb +70 -0
- data/lib/plan_my_stuff/reminders/fire.rb +129 -0
- data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
- data/lib/plan_my_stuff/reminders.rb +12 -0
- data/lib/plan_my_stuff/repo.rb +145 -0
- data/lib/plan_my_stuff/test_helpers.rb +265 -25
- data/lib/plan_my_stuff/testing_project.rb +292 -0
- data/lib/plan_my_stuff/testing_project_item.rb +218 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +24 -3
- data/lib/plan_my_stuff/verifier.rb +10 -0
- data/lib/plan_my_stuff/version.rb +2 -2
- data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
- data/lib/plan_my_stuff.rb +55 -20
- data/lib/tasks/plan_my_stuff.rake +331 -0
- metadata +99 -4
data/lib/plan_my_stuff/issue.rb
CHANGED
|
@@ -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
|
-
#
|
|
13
|
-
|
|
14
|
-
#
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
# @return [
|
|
28
|
-
|
|
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
|
-
|
|
116
|
+
visibility: 'public',
|
|
117
|
+
visibility_allowlist: [],
|
|
118
|
+
issue_type: nil,
|
|
119
|
+
issue_fields: nil,
|
|
120
|
+
attachments: []
|
|
53
121
|
)
|
|
54
|
-
|
|
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
|
-
|
|
148
|
+
resolved_type = resolve_issue_type!(issue_type)
|
|
66
149
|
|
|
67
150
|
options = {}
|
|
68
|
-
options[:labels] = labels if labels.
|
|
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 =
|
|
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]
|
|
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!(
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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)
|
|
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
|
-
#
|
|
135
|
-
#
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
167
|
-
|
|
401
|
+
params = { state: state.to_s, page: page, per_page: per_page }
|
|
402
|
+
params[:labels] = labels.sort.join(',') if labels.present?
|
|
168
403
|
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
#
|
|
528
|
+
# Polls a previously-submitted import for its current status.
|
|
192
529
|
#
|
|
193
|
-
# @
|
|
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 [
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
229
|
-
#
|
|
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
|
-
# @
|
|
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
|
-
|
|
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
|
-
@
|
|
282
|
-
@raw_body = nil
|
|
283
|
-
@metadata = IssueMetadata.new
|
|
810
|
+
@body_dirty = false
|
|
284
811
|
super
|
|
285
|
-
@labels ||= []
|
|
286
812
|
end
|
|
287
813
|
|
|
288
|
-
#
|
|
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
|
-
# @
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
311
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
375
|
-
|
|
376
|
-
return
|
|
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
|
-
|
|
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
|
-
#
|
|
385
|
-
#
|
|
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
|
-
# @
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
@
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
@
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
@
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
1282
|
+
return if updated_at.nil?
|
|
452
1283
|
|
|
453
1284
|
remote = self.class.find(number, repo: repo)
|
|
454
|
-
remote_time = remote.
|
|
455
|
-
local_time =
|
|
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(
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|