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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module IssueExtractions
|
|
5
|
+
module Waiting
|
|
6
|
+
# @return [Boolean]
|
|
7
|
+
def awaiting_reply?
|
|
8
|
+
issue_fields['Issue Status'] == 'Waiting on Reply'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Marks the issue as waiting on an end-user reply. Sets +metadata.waiting_on_user_at+ to now, (re)computes
|
|
12
|
+
# +metadata.next_reminder_at+, and adds the configured +waiting_on_user_label+ to the issue. Called from
|
|
13
|
+
# +Comment.create!+ when a support user posts a comment with +waiting_on_reply: true+, and from the
|
|
14
|
+
# +Issues::WaitingsController+ toggle.
|
|
15
|
+
#
|
|
16
|
+
# @param user [Object, nil] actor for the label notification event
|
|
17
|
+
#
|
|
18
|
+
# @return [self]
|
|
19
|
+
#
|
|
20
|
+
def enter_waiting_on_user!(user: nil)
|
|
21
|
+
now = Time.now.utc
|
|
22
|
+
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
23
|
+
|
|
24
|
+
PlanMyStuff::Label.ensure!(repo: repo, name: label)
|
|
25
|
+
PlanMyStuff::Label.add!(issue: self, labels: [label], user: user) if labels.exclude?(label)
|
|
26
|
+
|
|
27
|
+
to_update = {}
|
|
28
|
+
if PlanMyStuff.configuration.issue_fields_enabled
|
|
29
|
+
to_update[:issue_fields] = { 'Issue Status' => 'Waiting on Reply' }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
self.class.update!(
|
|
33
|
+
number: number,
|
|
34
|
+
repo: repo,
|
|
35
|
+
metadata: {
|
|
36
|
+
waiting_on_user_at: PlanMyStuff.format_time(now),
|
|
37
|
+
next_reminder_at: format_next_reminder_at(from: now),
|
|
38
|
+
},
|
|
39
|
+
**to_update,
|
|
40
|
+
)
|
|
41
|
+
reload
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Clears the waiting-on-user state: removes the label, clears +metadata.waiting_on_user_at+, and clears
|
|
45
|
+
# +metadata.next_reminder_at+ unless a waiting-on-approval timer is still active. No-ops if the issue is not
|
|
46
|
+
# currently waiting on a user reply.
|
|
47
|
+
#
|
|
48
|
+
# @return [self]
|
|
49
|
+
#
|
|
50
|
+
def clear_waiting_on_user!
|
|
51
|
+
label = PlanMyStuff.configuration.waiting_on_user_label
|
|
52
|
+
return self if metadata.waiting_on_user_at.nil? && labels.exclude?(label)
|
|
53
|
+
|
|
54
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
|
|
55
|
+
|
|
56
|
+
to_update = {}
|
|
57
|
+
if PlanMyStuff.configuration.issue_fields_enabled
|
|
58
|
+
to_update[:issue_fields] = { 'Issue Status' => 'Open' }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
self.class.update!(
|
|
62
|
+
number: number,
|
|
63
|
+
repo: repo,
|
|
64
|
+
metadata: {
|
|
65
|
+
waiting_on_user_at: nil,
|
|
66
|
+
next_reminder_at:
|
|
67
|
+
metadata.waiting_on_approval_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
|
|
68
|
+
},
|
|
69
|
+
**to_update,
|
|
70
|
+
)
|
|
71
|
+
reload
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Reopens an issue that was auto-closed by the inactivity sweep, clears +metadata.closed_by_inactivity+, and
|
|
75
|
+
# emits +issue_reopened_by_reply.plan_my_stuff+ carrying the reopening comment. Does not emit the regular
|
|
76
|
+
# +issue.reopened+ event \- subscribers that specifically care about this flow subscribe to the dedicated event.
|
|
77
|
+
#
|
|
78
|
+
# @param comment [PlanMyStuff::Comment] the reopening comment
|
|
79
|
+
# @param user [Object, nil] actor for the notification event
|
|
80
|
+
#
|
|
81
|
+
# @return [self]
|
|
82
|
+
#
|
|
83
|
+
def reopen_by_reply!(comment:, user: nil)
|
|
84
|
+
inactive_label = PlanMyStuff.configuration.user_inactive_label
|
|
85
|
+
PlanMyStuff::Label.remove!(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
|
|
86
|
+
|
|
87
|
+
to_update = {}
|
|
88
|
+
if PlanMyStuff.configuration.issue_fields_enabled
|
|
89
|
+
to_update[:issue_fields] = { 'Issue Status' => 'Reopened' }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
self.class.update!(
|
|
93
|
+
number: number,
|
|
94
|
+
repo: repo,
|
|
95
|
+
state: :open,
|
|
96
|
+
metadata: { closed_by_inactivity: false },
|
|
97
|
+
**to_update,
|
|
98
|
+
)
|
|
99
|
+
reload
|
|
100
|
+
|
|
101
|
+
PlanMyStuff::Notifications.instrument(
|
|
102
|
+
'issue_reopened_by_reply',
|
|
103
|
+
self,
|
|
104
|
+
user: user,
|
|
105
|
+
comment: comment,
|
|
106
|
+
)
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Formats the next reminder time as an ISO 8601 UTC string, using per-issue +metadata.reminder_days+ when set
|
|
113
|
+
# or +config.reminder_days+ otherwise. Returns +nil+ when the effective schedule is empty.
|
|
114
|
+
#
|
|
115
|
+
# @param from [Time] baseline timestamp
|
|
116
|
+
#
|
|
117
|
+
# @return [String, nil]
|
|
118
|
+
#
|
|
119
|
+
def format_next_reminder_at(from:)
|
|
120
|
+
days = metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
|
|
121
|
+
return if days.empty?
|
|
122
|
+
|
|
123
|
+
PlanMyStuff.format_time(from + days.first.days)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# When an issue is transitioning from open to closed, strips both waiting labels from the outgoing labels
|
|
127
|
+
# array and clears the waiting-related timestamps on +metadata+ so a single save writes both state change and
|
|
128
|
+
# cleanup. No-op for any other transition.
|
|
129
|
+
#
|
|
130
|
+
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
131
|
+
#
|
|
132
|
+
# @return [void]
|
|
133
|
+
#
|
|
134
|
+
def clear_waiting_state_on_close(attrs)
|
|
135
|
+
return unless state_changed?
|
|
136
|
+
return unless state_was == 'open'
|
|
137
|
+
return unless state == 'closed'
|
|
138
|
+
|
|
139
|
+
return if metadata.waiting_on_user_at.blank? && metadata.waiting_on_approval_at.blank?
|
|
140
|
+
|
|
141
|
+
waiting_labels = [
|
|
142
|
+
PlanMyStuff.configuration.waiting_on_user_label,
|
|
143
|
+
PlanMyStuff.configuration.waiting_on_approval_label,
|
|
144
|
+
]
|
|
145
|
+
attrs[:labels] = Array.wrap(attrs[:labels]) - waiting_labels
|
|
146
|
+
|
|
147
|
+
metadata.waiting_on_user_at = nil
|
|
148
|
+
metadata.waiting_on_approval_at = nil
|
|
149
|
+
metadata.next_reminder_at = nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# When an inactivity-closed issue is being reopened, strips the +user_inactive_label+ from the outgoing labels
|
|
153
|
+
# and clears +metadata.closed_by_inactivity+ so the save writes both. No-op for any other transition or for
|
|
154
|
+
# reopens of non-inactive closes.
|
|
155
|
+
#
|
|
156
|
+
# @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
|
|
157
|
+
#
|
|
158
|
+
# @return [void]
|
|
159
|
+
#
|
|
160
|
+
def clear_inactivity_state_on_reopen(attrs)
|
|
161
|
+
return unless state_changed?
|
|
162
|
+
return unless state_was == 'closed'
|
|
163
|
+
return unless state == 'open'
|
|
164
|
+
return unless metadata.closed_by_inactivity
|
|
165
|
+
|
|
166
|
+
attrs[:labels] = Array.wrap(attrs[:labels]) - [PlanMyStuff.configuration.user_inactive_label]
|
|
167
|
+
metadata.closed_by_inactivity = false
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Value object wrapping an organization-level GitHub Issue Field definition (public preview).
|
|
5
|
+
# Issue Fields are structured per-issue metadata (text, number, date, or single-select)
|
|
6
|
+
# configured once at the org level and applied across all of the org's repositories.
|
|
7
|
+
#
|
|
8
|
+
# Read-only on the gem side: callers manage field *definitions* through the GitHub UI, while
|
|
9
|
+
# the gem only handles field *values* on individual issues (see +Issue#issue_fields+).
|
|
10
|
+
class IssueField
|
|
11
|
+
# GraphQL +__typename+ -> normalized type symbol used internally.
|
|
12
|
+
TYPES = {
|
|
13
|
+
IssueFieldText: :text,
|
|
14
|
+
IssueFieldNumber: :number,
|
|
15
|
+
IssueFieldDate: :date,
|
|
16
|
+
IssueFieldSingleSelect: :single_select,
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# @return [String] GraphQL node ID, e.g. +"IFSS_kgDOAAGskA"+
|
|
20
|
+
attr_reader :id
|
|
21
|
+
|
|
22
|
+
# @return [String] display name (e.g. +"Priority"+)
|
|
23
|
+
attr_reader :name
|
|
24
|
+
|
|
25
|
+
# @return [Symbol] one of +:text+, +:number+, +:date+, +:single_select+
|
|
26
|
+
attr_reader :type
|
|
27
|
+
|
|
28
|
+
# @return [String, nil]
|
|
29
|
+
attr_reader :description
|
|
30
|
+
|
|
31
|
+
# @return [Array<Hash>] for +:single_select+, the option list as returned by GraphQL with symbol keys
|
|
32
|
+
# (+id+, +name+, +description+, +color+). Empty for other field types.
|
|
33
|
+
attr_reader :options
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Lists Issue Field definitions configured on the org.
|
|
37
|
+
#
|
|
38
|
+
# @raise [PlanMyStuff::IssueFieldsNotEnabledError] if +config.issue_fields_enabled+ is +false+
|
|
39
|
+
#
|
|
40
|
+
# @param org [String, nil] org login; defaults to +config.organization+
|
|
41
|
+
#
|
|
42
|
+
# @return [Array<PlanMyStuff::IssueField>]
|
|
43
|
+
#
|
|
44
|
+
def list(org: nil)
|
|
45
|
+
ensure_enabled!
|
|
46
|
+
|
|
47
|
+
org_login = org || PlanMyStuff.configuration.organization
|
|
48
|
+
data = PlanMyStuff.client.graphql(
|
|
49
|
+
PlanMyStuff::GraphQL::Queries::LIST_ORG_ISSUE_FIELDS,
|
|
50
|
+
variables: { org: org_login },
|
|
51
|
+
)
|
|
52
|
+
Array.wrap(data.dig(:organization, :issueFields, :nodes)).map { |node| from_graphql(node) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param name [String, Symbol]
|
|
56
|
+
# @param org [String, nil]
|
|
57
|
+
#
|
|
58
|
+
# @return [PlanMyStuff::IssueField, nil]
|
|
59
|
+
#
|
|
60
|
+
def find(name, org: nil)
|
|
61
|
+
list(org: org).find { |field| field.name.casecmp?(name.to_s) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param node [Hash] one node from +LIST_ORG_ISSUE_FIELDS+
|
|
65
|
+
#
|
|
66
|
+
# @return [PlanMyStuff::IssueField]
|
|
67
|
+
#
|
|
68
|
+
def from_graphql(node)
|
|
69
|
+
typename = node[:__typename]
|
|
70
|
+
type = TYPES[typename.to_sym] if typename
|
|
71
|
+
raise(PlanMyStuff::Error, "Unknown Issue Field typename: #{typename.inspect}") if type.nil?
|
|
72
|
+
|
|
73
|
+
new(
|
|
74
|
+
id: node.fetch(:id),
|
|
75
|
+
name: node.fetch(:name),
|
|
76
|
+
type: type,
|
|
77
|
+
description: node[:description],
|
|
78
|
+
options: Array.wrap(node[:options]),
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# @raise [PlanMyStuff::IssueFieldsNotEnabledError]
|
|
85
|
+
#
|
|
86
|
+
# @return [void]
|
|
87
|
+
#
|
|
88
|
+
def ensure_enabled!
|
|
89
|
+
return if PlanMyStuff.configuration.issue_fields_enabled
|
|
90
|
+
|
|
91
|
+
raise(PlanMyStuff::IssueFieldsNotEnabledError)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param id [String]
|
|
96
|
+
# @param name [String]
|
|
97
|
+
# @param type [Symbol]
|
|
98
|
+
# @param description [String, nil]
|
|
99
|
+
# @param options [Array<Hash>]
|
|
100
|
+
#
|
|
101
|
+
def initialize(id:, name:, type:, description: nil, options: [])
|
|
102
|
+
@id = id
|
|
103
|
+
@name = name
|
|
104
|
+
@type = type
|
|
105
|
+
@description = description
|
|
106
|
+
@options = options
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Resolves a single-select option name to its GraphQL node ID.
|
|
110
|
+
#
|
|
111
|
+
# @raise [PlanMyStuff::Error] if this field is not a single-select, or the option name is unknown
|
|
112
|
+
#
|
|
113
|
+
# @param option_name [String, Symbol]
|
|
114
|
+
#
|
|
115
|
+
# @return [String]
|
|
116
|
+
#
|
|
117
|
+
def option_id_for!(option_name)
|
|
118
|
+
raise(PlanMyStuff::Error, "Field #{name.inspect} is not a single-select") unless type == :single_select
|
|
119
|
+
|
|
120
|
+
match = options.find { |option| option.fetch(:name).casecmp?(option_name.to_s) }
|
|
121
|
+
raise(PlanMyStuff::Error, "Unknown option #{option_name.inspect} for field #{name.inspect}") if match.nil?
|
|
122
|
+
|
|
123
|
+
match.fetch(:id)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Translates between the canonical Issue Field names / values the gem refers to internally (e.g. +"Issue Status"+,
|
|
5
|
+
# +"Waiting on Reply"+) and the names / values a consuming org actually uses on GitHub. Driven by
|
|
6
|
+
# +config.issue_field_names+ (canonical field name => consumer field name) and +config.issue_field_values+
|
|
7
|
+
# (canonical field name => { canonical value => consumer value }).
|
|
8
|
+
#
|
|
9
|
+
# Outbound (canonical -> consumer) translation happens on writes and filters; inbound (consumer -> canonical) on
|
|
10
|
+
# reads, so internal comparisons like +issue_fields['Issue Status'] == 'Waiting on Reply'+ keep working regardless of
|
|
11
|
+
# how the org named the field or option. Unconfigured names / values pass through unchanged (identity fallback,
|
|
12
|
+
# mirroring +config.pipeline_statuses+).
|
|
13
|
+
module IssueFieldTranslation
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param canonical [String, Symbol] canonical field name
|
|
17
|
+
#
|
|
18
|
+
# @return [String] the consumer's field name, or the canonical name when unmapped
|
|
19
|
+
#
|
|
20
|
+
def consumer_field_name(canonical)
|
|
21
|
+
PlanMyStuff.configuration.issue_field_names.fetch(canonical.to_s, canonical.to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param consumer [String] the consumer's field name (as GitHub returns it)
|
|
25
|
+
#
|
|
26
|
+
# @return [String] the canonical field name, or the consumer name when unmapped
|
|
27
|
+
#
|
|
28
|
+
def canonical_field_name(consumer)
|
|
29
|
+
PlanMyStuff.configuration.issue_field_names.invert.fetch(consumer, consumer)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Translates a canonical value to the consumer's value for the given canonical field. Non-String values (numbers,
|
|
33
|
+
# dates, +nil+) pass through untouched -- only single-select / text labels are translatable.
|
|
34
|
+
#
|
|
35
|
+
# @param canonical_field [String, Symbol] canonical field name
|
|
36
|
+
# @param value [Object, nil] canonical value
|
|
37
|
+
#
|
|
38
|
+
# @return [Object, nil] the consumer's value, or the input when unmapped / non-String
|
|
39
|
+
#
|
|
40
|
+
def consumer_value(canonical_field, value)
|
|
41
|
+
return value unless value.is_a?(String)
|
|
42
|
+
|
|
43
|
+
value_map(canonical_field).fetch(value, value)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Translates a consumer value back to the canonical value for the given canonical field.
|
|
47
|
+
#
|
|
48
|
+
# @param canonical_field [String, Symbol] canonical field name
|
|
49
|
+
# @param value [Object, nil] consumer value
|
|
50
|
+
#
|
|
51
|
+
# @return [Object, nil] the canonical value, or the input when unmapped / non-String
|
|
52
|
+
#
|
|
53
|
+
def canonical_value(canonical_field, value)
|
|
54
|
+
return value unless value.is_a?(String)
|
|
55
|
+
|
|
56
|
+
value_map(canonical_field).invert.fetch(value, value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param canonical_field [String, Symbol]
|
|
60
|
+
#
|
|
61
|
+
# @return [Hash{String => String}] canonical-value => consumer-value map for the field (empty when unconfigured)
|
|
62
|
+
#
|
|
63
|
+
def value_map(canonical_field)
|
|
64
|
+
PlanMyStuff.configuration.issue_field_values.fetch(canonical_field.to_s, {})
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Hash-like read-side view of GitHub Issue Field values on a single +Issue+. Returned by
|
|
5
|
+
# +Issue#issue_fields+. Values are coerced into Ruby types on construction: date fields come back
|
|
6
|
+
# as +Date+, number fields as +Float+, single-select fields as the option name +String+, and
|
|
7
|
+
# text fields as the raw +String+.
|
|
8
|
+
#
|
|
9
|
+
# Access is by canonical field name (consumer names / values are reverse-translated via
|
|
10
|
+
# +PlanMyStuff::IssueFieldTranslation+ on construction); string and symbol keys both work. Iteration yields
|
|
11
|
+
# +[name, value]+ pairs in the order GitHub returned them.
|
|
12
|
+
class IssueFieldValueSet
|
|
13
|
+
include Enumerable
|
|
14
|
+
|
|
15
|
+
delegate :empty?, to: :@hash
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# @param nodes [Array<Hash>, nil] +issueFieldValues.nodes+ from the GraphQL read query
|
|
19
|
+
#
|
|
20
|
+
# @return [PlanMyStuff::IssueFieldValueSet]
|
|
21
|
+
#
|
|
22
|
+
def from_graphql(nodes)
|
|
23
|
+
pairs = Array.wrap(nodes).map do |node|
|
|
24
|
+
canonical_name = PlanMyStuff::IssueFieldTranslation.canonical_field_name(node.dig(:field, :name))
|
|
25
|
+
[canonical_name, PlanMyStuff::IssueFieldTranslation.canonical_value(canonical_name, coerce(node))]
|
|
26
|
+
end
|
|
27
|
+
new(pairs.to_h)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param node [Hash]
|
|
31
|
+
#
|
|
32
|
+
# @return [Object]
|
|
33
|
+
#
|
|
34
|
+
def coerce(node)
|
|
35
|
+
case node[:__typename].to_s
|
|
36
|
+
when 'IssueFieldDateValue' then Date.parse(node.fetch(:value))
|
|
37
|
+
when 'IssueFieldNumberValue' then node.fetch(:value).to_f
|
|
38
|
+
when 'IssueFieldSingleSelectValue' then node.fetch(:name)
|
|
39
|
+
else node.fetch(:value)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param hash [Hash{String => Object}]
|
|
45
|
+
#
|
|
46
|
+
def initialize(hash)
|
|
47
|
+
@hash = hash
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param name [String, Symbol] field display name
|
|
51
|
+
#
|
|
52
|
+
# @return [Object, nil]
|
|
53
|
+
#
|
|
54
|
+
def [](name)
|
|
55
|
+
@hash[name.to_s]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash{String => Object}] copy of the underlying hash
|
|
59
|
+
def to_h
|
|
60
|
+
@hash.dup
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Enumerator, void]
|
|
64
|
+
def each(&)
|
|
65
|
+
@hash.each(&)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -1,17 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
class IssueMetadata < BaseMetadata
|
|
4
|
+
class IssueMetadata < PlanMyStuff::BaseMetadata
|
|
5
5
|
# @return [Time, nil] first support action timestamp, nil until set
|
|
6
6
|
attr_accessor :responded_at
|
|
7
7
|
# @return [String, nil] user-facing URL in the consuming app
|
|
8
8
|
attr_accessor :issues_url
|
|
9
|
-
# @return [Boolean] whether this issue appears on the priority dashboard
|
|
10
|
-
attr_accessor :priority_list
|
|
11
|
-
# @return [Integer] sort order on priority dashboard (-1 = unranked)
|
|
12
|
-
attr_accessor :priority_list_priority
|
|
13
9
|
# @return [Array<Integer>] user IDs of non-support users allowed to view internal comments
|
|
14
10
|
attr_accessor :visibility_allowlist
|
|
11
|
+
# @return [String, nil] merged PR commit SHA for release tracking
|
|
12
|
+
attr_accessor :commit_sha
|
|
13
|
+
# @return [Boolean] whether to auto-complete on deployment (default: true)
|
|
14
|
+
attr_accessor :auto_complete
|
|
15
|
+
# @return [Array<PlanMyStuff::Link>] metadata-backed issue relationships.
|
|
16
|
+
# Only +:related+ links live here; native relationships (blocking,
|
|
17
|
+
# parent, sub_ticket, duplicate_of) live on GitHub.
|
|
18
|
+
attr_accessor :links
|
|
19
|
+
# @return [Array<PlanMyStuff::Approval>] manager approvals required on
|
|
20
|
+
# this issue. See +Issue.request_approvals!+ and +Issue#fully_approved?+.
|
|
21
|
+
attr_accessor :approvals
|
|
22
|
+
# @return [Time, nil] when the issue entered "waiting on user reply" state
|
|
23
|
+
attr_accessor :waiting_on_user_at
|
|
24
|
+
# @return [Time, nil] when the issue entered "waiting on approval" state
|
|
25
|
+
attr_accessor :waiting_on_approval_at
|
|
26
|
+
# @return [Time, nil] when the next reminder event should fire for this issue
|
|
27
|
+
attr_accessor :next_reminder_at
|
|
28
|
+
# @return [Array<Integer>, nil] per-issue override of +config.reminder_days+;
|
|
29
|
+
# applies to both waiting kinds
|
|
30
|
+
attr_accessor :reminder_days
|
|
31
|
+
# @return [Boolean] whether this issue was auto-closed by the inactivity sweep
|
|
32
|
+
attr_accessor :closed_by_inactivity
|
|
33
|
+
# @return [Time, nil] when the archive sweep tagged this issue as archived
|
|
34
|
+
attr_accessor :archived_at
|
|
15
35
|
|
|
16
36
|
class << self
|
|
17
37
|
# Builds an IssueMetadata from a parsed hash (e.g. from MetadataParser)
|
|
@@ -22,13 +42,21 @@ module PlanMyStuff
|
|
|
22
42
|
#
|
|
23
43
|
def from_hash(hash)
|
|
24
44
|
metadata = new
|
|
25
|
-
apply_common_from_hash(metadata, hash)
|
|
45
|
+
apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:issue))
|
|
26
46
|
|
|
27
47
|
metadata.responded_at = parse_time(hash[:responded_at])
|
|
28
48
|
metadata.issues_url = hash[:issues_url]
|
|
29
|
-
metadata.priority_list = hash.fetch(:priority_list, false)
|
|
30
|
-
metadata.priority_list_priority = hash.fetch(:priority_list_priority, -1)
|
|
31
49
|
metadata.visibility_allowlist = Array.wrap(hash[:visibility_allowlist])
|
|
50
|
+
metadata.commit_sha = hash[:commit_sha]
|
|
51
|
+
metadata.auto_complete = hash.fetch(:auto_complete, true)
|
|
52
|
+
metadata.links = normalize_links(hash[:links])
|
|
53
|
+
metadata.approvals = normalize_approvals(hash[:approvals])
|
|
54
|
+
metadata.waiting_on_user_at = parse_time(hash[:waiting_on_user_at])
|
|
55
|
+
metadata.waiting_on_approval_at = parse_time(hash[:waiting_on_approval_at])
|
|
56
|
+
metadata.next_reminder_at = parse_time(hash[:next_reminder_at])
|
|
57
|
+
metadata.reminder_days = normalize_reminder_days(hash[:reminder_days])
|
|
58
|
+
metadata.closed_by_inactivity = hash.fetch(:closed_by_inactivity, false)
|
|
59
|
+
metadata.archived_at = parse_time(hash[:archived_at])
|
|
32
60
|
|
|
33
61
|
metadata
|
|
34
62
|
end
|
|
@@ -42,13 +70,27 @@ module PlanMyStuff
|
|
|
42
70
|
#
|
|
43
71
|
def build(user:, visibility: 'public', custom_fields: {})
|
|
44
72
|
metadata = new
|
|
45
|
-
apply_common_build(
|
|
73
|
+
apply_common_build(
|
|
74
|
+
metadata,
|
|
75
|
+
user: user,
|
|
76
|
+
visibility: visibility,
|
|
77
|
+
custom_fields_data: custom_fields,
|
|
78
|
+
custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:issue),
|
|
79
|
+
)
|
|
46
80
|
|
|
47
81
|
metadata.responded_at = nil
|
|
48
82
|
metadata.issues_url = build_issues_url(PlanMyStuff.configuration)
|
|
49
|
-
metadata.priority_list = false
|
|
50
|
-
metadata.priority_list_priority = -1
|
|
51
83
|
metadata.visibility_allowlist = []
|
|
84
|
+
metadata.commit_sha = nil
|
|
85
|
+
metadata.auto_complete = true
|
|
86
|
+
metadata.links = []
|
|
87
|
+
metadata.approvals = []
|
|
88
|
+
metadata.waiting_on_user_at = nil
|
|
89
|
+
metadata.waiting_on_approval_at = nil
|
|
90
|
+
metadata.next_reminder_at = nil
|
|
91
|
+
metadata.reminder_days = nil
|
|
92
|
+
metadata.closed_by_inactivity = false
|
|
93
|
+
metadata.archived_at = nil
|
|
52
94
|
|
|
53
95
|
metadata
|
|
54
96
|
end
|
|
@@ -61,18 +103,79 @@ module PlanMyStuff
|
|
|
61
103
|
|
|
62
104
|
config.issues_url_prefix.to_s
|
|
63
105
|
end
|
|
106
|
+
|
|
107
|
+
# Builds a +PlanMyStuff::Link+ from each parsed entry. Malformed
|
|
108
|
+
# entries (wrong shape, missing fields, invalid values) are
|
|
109
|
+
# silently dropped so a single bad entry doesn't crash
|
|
110
|
+
# +Issue.find+ for an otherwise healthy issue.
|
|
111
|
+
#
|
|
112
|
+
# @param raw [Array, nil]
|
|
113
|
+
#
|
|
114
|
+
# @return [Array<PlanMyStuff::Link>]
|
|
115
|
+
#
|
|
116
|
+
def normalize_links(raw)
|
|
117
|
+
Array.wrap(raw).filter_map do |entry|
|
|
118
|
+
PlanMyStuff::Link.build!(entry)
|
|
119
|
+
rescue ActiveModel::ValidationError, ArgumentError
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Builds a +PlanMyStuff::Approval+ from each parsed entry.
|
|
125
|
+
# Malformed entries (wrong shape, missing fields, invalid values,
|
|
126
|
+
# unknown attributes) are silently dropped so a single bad entry
|
|
127
|
+
# doesn't crash +Issue.find+ for an otherwise healthy issue.
|
|
128
|
+
#
|
|
129
|
+
# @param raw [Array, nil]
|
|
130
|
+
#
|
|
131
|
+
# @return [Array<PlanMyStuff::Approval>]
|
|
132
|
+
#
|
|
133
|
+
def normalize_approvals(raw)
|
|
134
|
+
Array.wrap(raw).filter_map do |entry|
|
|
135
|
+
approval = PlanMyStuff::Approval.new(entry.transform_keys(&:to_sym))
|
|
136
|
+
approval.validate!
|
|
137
|
+
approval
|
|
138
|
+
rescue ActiveModel::ValidationError, ArgumentError, NoMethodError
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Normalizes a raw +reminder_days+ value from parsed metadata.
|
|
144
|
+
# Returns +nil+ when absent so callers can fall back to config;
|
|
145
|
+
# otherwise returns the array with non-integer entries dropped.
|
|
146
|
+
#
|
|
147
|
+
# @param raw [Object]
|
|
148
|
+
#
|
|
149
|
+
# @return [Array<Integer>, nil]
|
|
150
|
+
#
|
|
151
|
+
def normalize_reminder_days(raw)
|
|
152
|
+
return if raw.nil?
|
|
153
|
+
|
|
154
|
+
Array.wrap(raw).filter_map do |entry|
|
|
155
|
+
Integer(entry)
|
|
156
|
+
rescue ArgumentError, TypeError
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
end
|
|
64
160
|
end
|
|
65
161
|
|
|
66
162
|
def initialize
|
|
67
163
|
super
|
|
68
|
-
@priority_list = false
|
|
69
|
-
@priority_list_priority = -1
|
|
70
164
|
@visibility_allowlist = []
|
|
165
|
+
@auto_complete = true
|
|
166
|
+
@links = []
|
|
167
|
+
@approvals = []
|
|
168
|
+
@waiting_on_user_at = nil
|
|
169
|
+
@waiting_on_approval_at = nil
|
|
170
|
+
@next_reminder_at = nil
|
|
171
|
+
@reminder_days = nil
|
|
172
|
+
@closed_by_inactivity = false
|
|
173
|
+
@archived_at = nil
|
|
71
174
|
end
|
|
72
175
|
|
|
73
176
|
# @return [Boolean]
|
|
74
|
-
def
|
|
75
|
-
!!
|
|
177
|
+
def auto_complete?
|
|
178
|
+
!!auto_complete
|
|
76
179
|
end
|
|
77
180
|
|
|
78
181
|
# @return [Boolean]
|
|
@@ -91,20 +194,28 @@ module PlanMyStuff
|
|
|
91
194
|
def visible_to?(user)
|
|
92
195
|
return true if public?
|
|
93
196
|
|
|
94
|
-
resolved = UserResolver.resolve(user)
|
|
95
|
-
return true if UserResolver.support?(resolved)
|
|
197
|
+
resolved = PlanMyStuff::UserResolver.resolve(user)
|
|
198
|
+
return true if PlanMyStuff::UserResolver.support?(resolved)
|
|
96
199
|
|
|
97
|
-
visibility_allowlist.include?(UserResolver.user_id(resolved))
|
|
200
|
+
visibility_allowlist.include?(PlanMyStuff::UserResolver.user_id(resolved))
|
|
98
201
|
end
|
|
99
202
|
|
|
100
203
|
# @return [Hash]
|
|
101
204
|
def to_h
|
|
102
205
|
super.merge(
|
|
103
|
-
responded_at: format_time(responded_at),
|
|
206
|
+
responded_at: PlanMyStuff.format_time(responded_at),
|
|
104
207
|
issues_url: issues_url,
|
|
105
|
-
priority_list: priority_list,
|
|
106
|
-
priority_list_priority: priority_list_priority,
|
|
107
208
|
visibility_allowlist: visibility_allowlist,
|
|
209
|
+
commit_sha: commit_sha,
|
|
210
|
+
auto_complete: auto_complete,
|
|
211
|
+
links: links.map(&:to_h),
|
|
212
|
+
approvals: approvals.map(&:to_h),
|
|
213
|
+
waiting_on_user_at: PlanMyStuff.format_time(waiting_on_user_at),
|
|
214
|
+
waiting_on_approval_at: PlanMyStuff.format_time(waiting_on_approval_at),
|
|
215
|
+
next_reminder_at: PlanMyStuff.format_time(next_reminder_at),
|
|
216
|
+
reminder_days: reminder_days,
|
|
217
|
+
closed_by_inactivity: closed_by_inactivity,
|
|
218
|
+
archived_at: PlanMyStuff.format_time(archived_at),
|
|
108
219
|
)
|
|
109
220
|
end
|
|
110
221
|
end
|