plan_my_stuff 0.3.0 → 0.5.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 +22 -0
- data/README.md +569 -38
- data/app/controllers/plan_my_stuff/comments_controller.rb +5 -1
- data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +102 -0
- data/app/controllers/plan_my_stuff/issues/closures_controller.rb +37 -0
- data/app/controllers/plan_my_stuff/issues/links_controller.rb +127 -0
- data/app/controllers/plan_my_stuff/issues/takes_controller.rb +88 -0
- data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +48 -0
- data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +47 -0
- data/app/controllers/plan_my_stuff/issues_controller.rb +22 -55
- data/app/controllers/plan_my_stuff/labels_controller.rb +4 -4
- data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +75 -0
- data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +40 -0
- data/app/controllers/plan_my_stuff/project_items_controller.rb +0 -75
- data/app/controllers/plan_my_stuff/projects_controller.rb +11 -1
- data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +54 -0
- data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +39 -0
- data/app/controllers/plan_my_stuff/testing_projects_controller.rb +93 -0
- data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +148 -0
- data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +284 -0
- data/app/jobs/plan_my_stuff/application_job.rb +9 -0
- data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +81 -0
- data/app/views/plan_my_stuff/comments/partials/_form.html.erb +7 -0
- data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +87 -0
- data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/partials/_links.html.erb +70 -0
- data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +2 -2
- data/app/views/plan_my_stuff/issues/show.html.erb +46 -3
- data/app/views/plan_my_stuff/projects/index.html.erb +15 -1
- data/app/views/plan_my_stuff/projects/show.html.erb +10 -5
- data/app/views/plan_my_stuff/testing_project_items/new.html.erb +12 -0
- data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +22 -0
- data/app/views/plan_my_stuff/testing_projects/edit.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/new.html.erb +7 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +39 -0
- data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +51 -0
- data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +35 -0
- data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
- data/config/routes.rb +38 -15
- data/lib/generators/plan_my_stuff/install/templates/initializer.rb +172 -5
- data/lib/plan_my_stuff/application_record.rb +121 -0
- data/lib/plan_my_stuff/approval.rb +80 -0
- data/lib/plan_my_stuff/archive/sweep.rb +85 -0
- data/lib/plan_my_stuff/archive.rb +14 -0
- data/lib/plan_my_stuff/aws_sns_simulator.rb +110 -0
- data/lib/plan_my_stuff/base_project.rb +661 -0
- data/lib/plan_my_stuff/base_project_item.rb +562 -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 +7 -0
- data/lib/plan_my_stuff/comment.rb +171 -50
- data/lib/plan_my_stuff/configuration.rb +210 -10
- data/lib/plan_my_stuff/custom_fields.rb +31 -17
- data/lib/plan_my_stuff/engine.rb +0 -4
- data/lib/plan_my_stuff/errors.rb +49 -0
- data/lib/plan_my_stuff/graphql/queries.rb +392 -0
- data/lib/plan_my_stuff/issue.rb +1476 -175
- data/lib/plan_my_stuff/issue_metadata.rb +122 -0
- data/lib/plan_my_stuff/label.rb +82 -11
- data/lib/plan_my_stuff/link.rb +144 -0
- data/lib/plan_my_stuff/notifications.rb +142 -0
- data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
- data/lib/plan_my_stuff/pipeline/status.rb +44 -0
- data/lib/plan_my_stuff/pipeline.rb +293 -0
- data/lib/plan_my_stuff/project.rb +30 -693
- data/lib/plan_my_stuff/project_item.rb +3 -417
- data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
- data/lib/plan_my_stuff/project_metadata.rb +9 -3
- 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 +16 -0
- data/lib/plan_my_stuff/test_helpers.rb +260 -15
- data/lib/plan_my_stuff/testing_project.rb +291 -0
- data/lib/plan_my_stuff/testing_project_item.rb +216 -0
- data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
- data/lib/plan_my_stuff/user_resolver.rb +8 -3
- data/lib/plan_my_stuff/version.rb +1 -1
- data/lib/plan_my_stuff/webhook_replayer.rb +280 -0
- data/lib/plan_my_stuff.rb +15 -0
- data/lib/tasks/plan_my_stuff.rake +179 -0
- metadata +77 -3
|
@@ -12,6 +12,30 @@ module PlanMyStuff
|
|
|
12
12
|
attr_accessor :priority_list_priority
|
|
13
13
|
# @return [Array<Integer>] user IDs of non-support users allowed to view internal comments
|
|
14
14
|
attr_accessor :visibility_allowlist
|
|
15
|
+
# @return [String, nil] merged PR commit SHA for release tracking
|
|
16
|
+
attr_accessor :commit_sha
|
|
17
|
+
# @return [Boolean] whether to auto-complete on deployment (default: true)
|
|
18
|
+
attr_accessor :auto_complete
|
|
19
|
+
# @return [Array<PlanMyStuff::Link>] metadata-backed issue relationships.
|
|
20
|
+
# Only +:related+ links live here; native relationships (blocking,
|
|
21
|
+
# parent, sub_ticket, duplicate_of) live on GitHub.
|
|
22
|
+
attr_accessor :links
|
|
23
|
+
# @return [Array<PlanMyStuff::Approval>] manager approvals required on
|
|
24
|
+
# this issue. See +Issue.request_approvals!+ and +Issue#fully_approved?+.
|
|
25
|
+
attr_accessor :approvals
|
|
26
|
+
# @return [Time, nil] when the issue entered "waiting on user reply" state
|
|
27
|
+
attr_accessor :waiting_on_user_at
|
|
28
|
+
# @return [Time, nil] when the issue entered "waiting on approval" state
|
|
29
|
+
attr_accessor :waiting_on_approval_at
|
|
30
|
+
# @return [Time, nil] when the next reminder event should fire for this issue
|
|
31
|
+
attr_accessor :next_reminder_at
|
|
32
|
+
# @return [Array<Integer>, nil] per-issue override of +config.reminder_days+;
|
|
33
|
+
# applies to both waiting kinds
|
|
34
|
+
attr_accessor :reminder_days
|
|
35
|
+
# @return [Boolean] whether this issue was auto-closed by the inactivity sweep
|
|
36
|
+
attr_accessor :closed_by_inactivity
|
|
37
|
+
# @return [Time, nil] when the archive sweep tagged this issue as archived
|
|
38
|
+
attr_accessor :archived_at
|
|
15
39
|
|
|
16
40
|
class << self
|
|
17
41
|
# Builds an IssueMetadata from a parsed hash (e.g. from MetadataParser)
|
|
@@ -29,6 +53,16 @@ module PlanMyStuff
|
|
|
29
53
|
metadata.priority_list = hash.fetch(:priority_list, false)
|
|
30
54
|
metadata.priority_list_priority = hash.fetch(:priority_list_priority, -1)
|
|
31
55
|
metadata.visibility_allowlist = Array.wrap(hash[:visibility_allowlist])
|
|
56
|
+
metadata.commit_sha = hash[:commit_sha]
|
|
57
|
+
metadata.auto_complete = hash.fetch(:auto_complete, true)
|
|
58
|
+
metadata.links = normalize_links(hash[:links])
|
|
59
|
+
metadata.approvals = normalize_approvals(hash[:approvals])
|
|
60
|
+
metadata.waiting_on_user_at = parse_time(hash[:waiting_on_user_at])
|
|
61
|
+
metadata.waiting_on_approval_at = parse_time(hash[:waiting_on_approval_at])
|
|
62
|
+
metadata.next_reminder_at = parse_time(hash[:next_reminder_at])
|
|
63
|
+
metadata.reminder_days = normalize_reminder_days(hash[:reminder_days])
|
|
64
|
+
metadata.closed_by_inactivity = hash.fetch(:closed_by_inactivity, false)
|
|
65
|
+
metadata.archived_at = parse_time(hash[:archived_at])
|
|
32
66
|
|
|
33
67
|
metadata
|
|
34
68
|
end
|
|
@@ -55,6 +89,16 @@ module PlanMyStuff
|
|
|
55
89
|
metadata.priority_list = false
|
|
56
90
|
metadata.priority_list_priority = -1
|
|
57
91
|
metadata.visibility_allowlist = []
|
|
92
|
+
metadata.commit_sha = nil
|
|
93
|
+
metadata.auto_complete = true
|
|
94
|
+
metadata.links = []
|
|
95
|
+
metadata.approvals = []
|
|
96
|
+
metadata.waiting_on_user_at = nil
|
|
97
|
+
metadata.waiting_on_approval_at = nil
|
|
98
|
+
metadata.next_reminder_at = nil
|
|
99
|
+
metadata.reminder_days = nil
|
|
100
|
+
metadata.closed_by_inactivity = false
|
|
101
|
+
metadata.archived_at = nil
|
|
58
102
|
|
|
59
103
|
metadata
|
|
60
104
|
end
|
|
@@ -67,6 +111,60 @@ module PlanMyStuff
|
|
|
67
111
|
|
|
68
112
|
config.issues_url_prefix.to_s
|
|
69
113
|
end
|
|
114
|
+
|
|
115
|
+
# Builds a +PlanMyStuff::Link+ from each parsed entry. Malformed
|
|
116
|
+
# entries (wrong shape, missing fields, invalid values) are
|
|
117
|
+
# silently dropped so a single bad entry doesn't crash
|
|
118
|
+
# +Issue.find+ for an otherwise healthy issue.
|
|
119
|
+
#
|
|
120
|
+
# @param raw [Array, nil]
|
|
121
|
+
#
|
|
122
|
+
# @return [Array<PlanMyStuff::Link>]
|
|
123
|
+
#
|
|
124
|
+
def normalize_links(raw)
|
|
125
|
+
Array.wrap(raw).filter_map do |entry|
|
|
126
|
+
PlanMyStuff::Link.build(entry)
|
|
127
|
+
rescue ActiveModel::ValidationError, ArgumentError
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Builds a +PlanMyStuff::Approval+ from each parsed entry.
|
|
133
|
+
# Malformed entries (wrong shape, missing fields, invalid values,
|
|
134
|
+
# unknown attributes) are silently dropped so a single bad entry
|
|
135
|
+
# doesn't crash +Issue.find+ for an otherwise healthy issue.
|
|
136
|
+
#
|
|
137
|
+
# @param raw [Array, nil]
|
|
138
|
+
#
|
|
139
|
+
# @return [Array<PlanMyStuff::Approval>]
|
|
140
|
+
#
|
|
141
|
+
def normalize_approvals(raw)
|
|
142
|
+
Array.wrap(raw).filter_map do |entry|
|
|
143
|
+
approval = PlanMyStuff::Approval.new(entry.transform_keys(&:to_sym))
|
|
144
|
+
approval.validate!
|
|
145
|
+
approval
|
|
146
|
+
rescue ActiveModel::ValidationError, ArgumentError, NoMethodError
|
|
147
|
+
next
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Normalizes a raw +reminder_days+ value from parsed metadata.
|
|
152
|
+
# Returns +nil+ when absent so callers can fall back to config;
|
|
153
|
+
# otherwise returns the array with non-integer entries dropped.
|
|
154
|
+
#
|
|
155
|
+
# @param raw [Object]
|
|
156
|
+
#
|
|
157
|
+
# @return [Array<Integer>, nil]
|
|
158
|
+
#
|
|
159
|
+
def normalize_reminder_days(raw)
|
|
160
|
+
return if raw.nil?
|
|
161
|
+
|
|
162
|
+
Array.wrap(raw).filter_map do |entry|
|
|
163
|
+
Integer(entry)
|
|
164
|
+
rescue ArgumentError, TypeError
|
|
165
|
+
next
|
|
166
|
+
end
|
|
167
|
+
end
|
|
70
168
|
end
|
|
71
169
|
|
|
72
170
|
def initialize
|
|
@@ -74,6 +172,20 @@ module PlanMyStuff
|
|
|
74
172
|
@priority_list = false
|
|
75
173
|
@priority_list_priority = -1
|
|
76
174
|
@visibility_allowlist = []
|
|
175
|
+
@auto_complete = true
|
|
176
|
+
@links = []
|
|
177
|
+
@approvals = []
|
|
178
|
+
@waiting_on_user_at = nil
|
|
179
|
+
@waiting_on_approval_at = nil
|
|
180
|
+
@next_reminder_at = nil
|
|
181
|
+
@reminder_days = nil
|
|
182
|
+
@closed_by_inactivity = false
|
|
183
|
+
@archived_at = nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @return [Boolean]
|
|
187
|
+
def auto_complete?
|
|
188
|
+
!!auto_complete
|
|
77
189
|
end
|
|
78
190
|
|
|
79
191
|
# @return [Boolean]
|
|
@@ -111,6 +223,16 @@ module PlanMyStuff
|
|
|
111
223
|
priority_list: priority_list,
|
|
112
224
|
priority_list_priority: priority_list_priority,
|
|
113
225
|
visibility_allowlist: visibility_allowlist,
|
|
226
|
+
commit_sha: commit_sha,
|
|
227
|
+
auto_complete: auto_complete,
|
|
228
|
+
links: links.map(&:to_h),
|
|
229
|
+
approvals: approvals.map(&:to_h),
|
|
230
|
+
waiting_on_user_at: format_time(waiting_on_user_at),
|
|
231
|
+
waiting_on_approval_at: format_time(waiting_on_approval_at),
|
|
232
|
+
next_reminder_at: format_time(next_reminder_at),
|
|
233
|
+
reminder_days: reminder_days,
|
|
234
|
+
closed_by_inactivity: closed_by_inactivity,
|
|
235
|
+
archived_at: format_time(archived_at),
|
|
114
236
|
)
|
|
115
237
|
end
|
|
116
238
|
end
|
data/lib/plan_my_stuff/label.rb
CHANGED
|
@@ -4,43 +4,104 @@ module PlanMyStuff
|
|
|
4
4
|
# Wraps a GitHub label with a reference to its parent issue.
|
|
5
5
|
# Class methods provide the public API for add/remove operations.
|
|
6
6
|
class Label < PlanMyStuff::ApplicationRecord
|
|
7
|
-
# @return [String]
|
|
8
|
-
|
|
9
|
-
# @return [PlanMyStuff::Issue]
|
|
10
|
-
|
|
7
|
+
# @return [String, nil]
|
|
8
|
+
attribute :name, :string
|
|
9
|
+
# @return [PlanMyStuff::Issue, nil]
|
|
10
|
+
attribute :issue
|
|
11
11
|
|
|
12
12
|
class << self
|
|
13
13
|
# Adds labels to a GitHub issue.
|
|
14
14
|
#
|
|
15
15
|
# @param issue [PlanMyStuff::Issue] parent issue
|
|
16
16
|
# @param labels [Array<String>]
|
|
17
|
+
# @param user [Object, nil] actor for the notification event
|
|
17
18
|
#
|
|
18
19
|
# @return [Array<PlanMyStuff::Label>]
|
|
19
20
|
#
|
|
20
|
-
def add(issue:, labels:)
|
|
21
|
+
def add(issue:, labels:, user: nil)
|
|
22
|
+
label_names = Array.wrap(labels)
|
|
23
|
+
|
|
21
24
|
result = PlanMyStuff.client.rest(
|
|
22
|
-
:add_labels_to_an_issue, issue.repo, issue.number,
|
|
25
|
+
:add_labels_to_an_issue, issue.repo, issue.number, label_names,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
PMS::Cache.delete_issue(issue.repo, issue.number)
|
|
29
|
+
|
|
30
|
+
PlanMyStuff::Notifications.instrument(
|
|
31
|
+
'label.added', issue, user: user, labels: label_names,
|
|
23
32
|
)
|
|
24
33
|
|
|
25
34
|
result.map { |gh_label| build(gh_label, issue: issue) }
|
|
26
35
|
end
|
|
27
36
|
|
|
37
|
+
# Ensures a label exists on the given repo, creating it if missing.
|
|
38
|
+
# Idempotent: a 404 from +label+ triggers creation; a 422 from
|
|
39
|
+
# +add_label+ (concurrent-creation race) is treated as success.
|
|
40
|
+
#
|
|
41
|
+
# @param repo [String, Symbol] repo name or key
|
|
42
|
+
# @param name [String] label name
|
|
43
|
+
# @param color [String] hex color without +#+
|
|
44
|
+
# @param description [String, nil]
|
|
45
|
+
#
|
|
46
|
+
# @return [void]
|
|
47
|
+
#
|
|
48
|
+
def ensure!(repo:, name:, color: 'fbca04', description: nil)
|
|
49
|
+
client = PlanMyStuff.client
|
|
50
|
+
client.rest(:label, repo, name)
|
|
51
|
+
rescue PlanMyStuff::APIError => e
|
|
52
|
+
raise unless e.status == 404
|
|
53
|
+
|
|
54
|
+
create_label(client, repo, name, color, description)
|
|
55
|
+
end
|
|
56
|
+
|
|
28
57
|
# Removes labels from a GitHub issue.
|
|
29
58
|
#
|
|
30
59
|
# @param issue [PlanMyStuff::Issue] parent issue
|
|
31
60
|
# @param labels [Array<String>]
|
|
61
|
+
# @param user [Object, nil] actor for the notification event
|
|
32
62
|
#
|
|
33
63
|
# @return [Array<Array<PlanMyStuff::Label>>] results of each removal
|
|
34
64
|
#
|
|
35
|
-
def remove(issue:, labels:)
|
|
36
|
-
Array.wrap(labels)
|
|
65
|
+
def remove(issue:, labels:, user: nil)
|
|
66
|
+
label_names = Array.wrap(labels)
|
|
67
|
+
|
|
68
|
+
results = label_names.map do |label|
|
|
37
69
|
result = PlanMyStuff.client.rest(:remove_label, issue.repo, issue.number, label)
|
|
38
70
|
result.map { |gh_label| build(gh_label, issue: issue) }
|
|
39
71
|
end
|
|
72
|
+
|
|
73
|
+
PMS::Cache.delete_issue(issue.repo, issue.number)
|
|
74
|
+
|
|
75
|
+
PlanMyStuff::Notifications.instrument(
|
|
76
|
+
'label.removed', issue, user: user, labels: label_names,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
results
|
|
40
80
|
end
|
|
41
81
|
|
|
42
82
|
private
|
|
43
83
|
|
|
84
|
+
# Creates a label, tolerating the 422 "already exists" race that
|
|
85
|
+
# occurs when a concurrent ensure! slipped in between our 404 read
|
|
86
|
+
# and this write.
|
|
87
|
+
#
|
|
88
|
+
# @param client [PlanMyStuff::Client]
|
|
89
|
+
# @param repo [String, Symbol]
|
|
90
|
+
# @param name [String]
|
|
91
|
+
# @param color [String]
|
|
92
|
+
# @param description [String, nil]
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
95
|
+
#
|
|
96
|
+
def create_label(client, repo, name, color, description)
|
|
97
|
+
options = {}
|
|
98
|
+
options[:description] = description if description
|
|
99
|
+
|
|
100
|
+
client.rest(:add_label, repo, name, color, **options)
|
|
101
|
+
rescue PlanMyStuff::APIError => e
|
|
102
|
+
raise unless e.status == 422
|
|
103
|
+
end
|
|
104
|
+
|
|
44
105
|
# Hydrates a Label from a GitHub API response.
|
|
45
106
|
#
|
|
46
107
|
# @param github_label [Object] Octokit label response
|
|
@@ -49,11 +110,21 @@ module PlanMyStuff
|
|
|
49
110
|
# @return [PlanMyStuff::Label]
|
|
50
111
|
#
|
|
51
112
|
def build(github_label, issue:)
|
|
52
|
-
|
|
53
|
-
label
|
|
54
|
-
label.
|
|
113
|
+
label = new(name: read_field(github_label, :name), issue: issue)
|
|
114
|
+
label.instance_variable_set(:@github_response, github_label)
|
|
115
|
+
label.__send__(:persisted!)
|
|
55
116
|
label
|
|
56
117
|
end
|
|
57
118
|
end
|
|
119
|
+
|
|
120
|
+
# Serializes the label to a JSON-safe hash, excluding the back-reference
|
|
121
|
+
# to the parent issue to prevent recursive serialization cycles.
|
|
122
|
+
#
|
|
123
|
+
# @return [Hash]
|
|
124
|
+
#
|
|
125
|
+
def as_json(options = {})
|
|
126
|
+
merged_except = Array.wrap(options[:except]) + ['issue']
|
|
127
|
+
super(options.merge(except: merged_except)).merge('issue_number' => issue&.number)
|
|
128
|
+
end
|
|
58
129
|
end
|
|
59
130
|
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
|
|
5
|
+
module PlanMyStuff
|
|
6
|
+
# Value object representing a typed relationship between two issues.
|
|
7
|
+
# Built by +Issue#add_related!+, +#add_blocker!+, +#add_sub_issue!+,
|
|
8
|
+
# +#set_parent!+, and +#mark_duplicate!+; also returned from their
|
|
9
|
+
# +remove_*+ counterparts so callers can render activity-log lines
|
|
10
|
+
# like +"Added parent issue: owner/repo#42"+.
|
|
11
|
+
#
|
|
12
|
+
# Metadata-backed types live in +IssueMetadata#links+; native types
|
|
13
|
+
# are routed through GitHub APIs and never persisted in our metadata.
|
|
14
|
+
class Link
|
|
15
|
+
METADATA_TYPES = %w[related].freeze
|
|
16
|
+
NATIVE_TYPES = %w[blocking blocked_by parent sub_ticket duplicate_of].freeze
|
|
17
|
+
ALL_TYPES = (METADATA_TYPES + NATIVE_TYPES).freeze
|
|
18
|
+
|
|
19
|
+
include ActiveModel::Model
|
|
20
|
+
include ActiveModel::Attributes
|
|
21
|
+
include ActiveModel::Serializers::JSON
|
|
22
|
+
|
|
23
|
+
# @return [String] one of ALL_TYPES
|
|
24
|
+
attribute :type, :string
|
|
25
|
+
# @return [Integer] target issue's GitHub issue number
|
|
26
|
+
attribute :issue_number, :integer
|
|
27
|
+
# @return [String] full "owner/name" path of the target issue's repo
|
|
28
|
+
attribute :repo, :string
|
|
29
|
+
|
|
30
|
+
validates :type, presence: true, inclusion: { in: ALL_TYPES }
|
|
31
|
+
validates :issue_number, presence: true, numericality: { greater_than: 0, only_integer: true }
|
|
32
|
+
validates :repo, presence: true
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Builds and validates a +Link+ from an +Issue+-like object, another
|
|
36
|
+
# +Link+, or a hash. +type:+ fills in when the input does not carry
|
|
37
|
+
# one; +source_repo+ fills in when the hash input does not carry
|
|
38
|
+
# one. Raises +ActiveModel::ValidationError+ on missing/invalid
|
|
39
|
+
# fields, +ArgumentError+ when +input+ is an unsupported shape.
|
|
40
|
+
#
|
|
41
|
+
# @param input [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
42
|
+
# @param type [String, Symbol, nil]
|
|
43
|
+
# @param source_repo [String, PlanMyStuff::Repo, nil]
|
|
44
|
+
#
|
|
45
|
+
# @return [PlanMyStuff::Link]
|
|
46
|
+
#
|
|
47
|
+
def build(input, type: nil, source_repo: nil)
|
|
48
|
+
return input if input.is_a?(PlanMyStuff::Link)
|
|
49
|
+
|
|
50
|
+
link =
|
|
51
|
+
if input.is_a?(Hash)
|
|
52
|
+
build_from_hash(input, type: type, source_repo: source_repo)
|
|
53
|
+
else
|
|
54
|
+
build_from_issue_like(input, type: type)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
link.validate!
|
|
58
|
+
link
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# @return [PlanMyStuff::Link]
|
|
64
|
+
def build_from_issue_like(input, type:)
|
|
65
|
+
if !input.respond_to?(:number) || !input.respond_to?(:repo)
|
|
66
|
+
raise(ArgumentError, "Cannot build Link from #{input.class}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
new(
|
|
70
|
+
type: type&.to_s,
|
|
71
|
+
issue_number: input.number,
|
|
72
|
+
repo: repo_string(input.repo),
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [PlanMyStuff::Link]
|
|
77
|
+
def build_from_hash(hash, type:, source_repo:)
|
|
78
|
+
data = hash.transform_keys(&:to_sym)
|
|
79
|
+
new(
|
|
80
|
+
type: (data[:type] || type)&.to_s,
|
|
81
|
+
issue_number: data[:issue_number],
|
|
82
|
+
repo: repo_string(data[:repo] || source_repo),
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [String, nil]
|
|
87
|
+
def repo_string(value)
|
|
88
|
+
return if value.nil?
|
|
89
|
+
return value.full_name if value.is_a?(PlanMyStuff::Repo)
|
|
90
|
+
|
|
91
|
+
value.to_s
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [String] e.g. "owner/repo#42"
|
|
96
|
+
def to_s
|
|
97
|
+
"#{repo}##{issue_number}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Hash]
|
|
101
|
+
def to_h
|
|
102
|
+
{
|
|
103
|
+
type: type,
|
|
104
|
+
issue_number: issue_number,
|
|
105
|
+
repo: repo,
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param other_repo [String, PlanMyStuff::Repo, nil]
|
|
110
|
+
#
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
#
|
|
113
|
+
def same_repo?(other_repo)
|
|
114
|
+
return false if other_repo.nil?
|
|
115
|
+
|
|
116
|
+
repo == self.class.__send__(:repo_string, other_repo)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Lazy-fetches and memoizes the target +Issue+.
|
|
120
|
+
#
|
|
121
|
+
# @return [PlanMyStuff::Issue]
|
|
122
|
+
#
|
|
123
|
+
def issue
|
|
124
|
+
@issue ||= PlanMyStuff::Issue.find(issue_number, repo: repo)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param other [Object]
|
|
128
|
+
#
|
|
129
|
+
# @return [Boolean]
|
|
130
|
+
#
|
|
131
|
+
def ==(other)
|
|
132
|
+
return false unless other.is_a?(PlanMyStuff::Link)
|
|
133
|
+
|
|
134
|
+
type == other.type && issue_number == other.issue_number && repo == other.repo
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
alias eql? ==
|
|
138
|
+
|
|
139
|
+
# @return [Integer]
|
|
140
|
+
def hash
|
|
141
|
+
[type, issue_number, repo].hash
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/notifications'
|
|
4
|
+
|
|
5
|
+
module PlanMyStuff
|
|
6
|
+
# Central instrumentation helper. Domain classes call
|
|
7
|
+
# +PlanMyStuff::Notifications.instrument+ at mutation points so
|
|
8
|
+
# consuming apps can subscribe for email, webhooks, Slack, etc.
|
|
9
|
+
#
|
|
10
|
+
# Events are fired under the +plan_my_stuff.<event>+ namespace via
|
|
11
|
+
# +ActiveSupport::Notifications+. Subscribers run synchronously.
|
|
12
|
+
#
|
|
13
|
+
module Notifications
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
EVENT_PREFIX = 'plan_my_stuff'
|
|
17
|
+
SKIPPED_LOG_KEYS = %i[user timestamp visibility visibility_allowlist].freeze
|
|
18
|
+
|
|
19
|
+
# Fires +plan_my_stuff.<event>+ with a normalized payload.
|
|
20
|
+
#
|
|
21
|
+
# @param event [String] e.g. +'issue.created'+
|
|
22
|
+
# @param resource [Object] domain object (+Issue+, +Comment+, +ProjectItem+, ...)
|
|
23
|
+
# @param user [Object, nil] explicit actor; falls back to +config.current_user+
|
|
24
|
+
# @param extra [Hash] additional payload entries (+changes:+, +labels:+, +user_ids:+, ...)
|
|
25
|
+
#
|
|
26
|
+
# @return [void]
|
|
27
|
+
#
|
|
28
|
+
def instrument(event, resource, user: nil, **extra)
|
|
29
|
+
actor = user || resolve_current_user
|
|
30
|
+
payload = build_payload(resource, actor, extra)
|
|
31
|
+
log(event, payload)
|
|
32
|
+
ActiveSupport::Notifications.instrument("#{EVENT_PREFIX}.#{event}", payload)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Invokes +config.current_user+ if it responds to +call+.
|
|
36
|
+
#
|
|
37
|
+
# @return [Object, nil]
|
|
38
|
+
#
|
|
39
|
+
def resolve_current_user
|
|
40
|
+
resolver = PlanMyStuff.configuration.current_user
|
|
41
|
+
return if resolver.nil?
|
|
42
|
+
|
|
43
|
+
resolver.respond_to?(:call) ? resolver.call : resolver
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Builds the payload hash for an event.
|
|
47
|
+
#
|
|
48
|
+
# @param resource [Object]
|
|
49
|
+
# @param actor [Object, nil]
|
|
50
|
+
# @param extra [Hash]
|
|
51
|
+
#
|
|
52
|
+
# @return [Hash]
|
|
53
|
+
#
|
|
54
|
+
def build_payload(resource, actor, extra)
|
|
55
|
+
payload = {
|
|
56
|
+
infer_resource_key(resource) => resource,
|
|
57
|
+
:user => actor,
|
|
58
|
+
:timestamp => Time.current,
|
|
59
|
+
}
|
|
60
|
+
payload.merge!(visibility_fields(resource))
|
|
61
|
+
payload.merge(extra)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Maps a resource object to its payload key.
|
|
65
|
+
#
|
|
66
|
+
# @param resource [Object]
|
|
67
|
+
#
|
|
68
|
+
# @return [Symbol]
|
|
69
|
+
#
|
|
70
|
+
def infer_resource_key(resource)
|
|
71
|
+
case resource
|
|
72
|
+
when PlanMyStuff::Issue then :issue
|
|
73
|
+
when PlanMyStuff::Comment then :comment
|
|
74
|
+
when PlanMyStuff::BaseProjectItem then :project_item
|
|
75
|
+
else :resource
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Extracts visibility + allowlist from +Issue+/+Comment+ resources.
|
|
80
|
+
# Returns an empty hash for resources without visibility.
|
|
81
|
+
#
|
|
82
|
+
# @param resource [Object]
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash]
|
|
85
|
+
#
|
|
86
|
+
def visibility_fields(resource)
|
|
87
|
+
case resource
|
|
88
|
+
when PlanMyStuff::Issue
|
|
89
|
+
{
|
|
90
|
+
visibility: resource.metadata.visibility,
|
|
91
|
+
visibility_allowlist: Array.wrap(resource.metadata.visibility_allowlist),
|
|
92
|
+
}
|
|
93
|
+
when PlanMyStuff::Comment
|
|
94
|
+
parent_allowlist = resource.issue ? resource.issue.metadata.visibility_allowlist : []
|
|
95
|
+
{
|
|
96
|
+
visibility: resource.visibility&.to_s,
|
|
97
|
+
visibility_allowlist: Array.wrap(parent_allowlist),
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
{}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Emits a debug log line for the event. No-op when no logger is
|
|
105
|
+
# available (e.g. outside Rails).
|
|
106
|
+
#
|
|
107
|
+
# @param event [String]
|
|
108
|
+
# @param payload [Hash]
|
|
109
|
+
#
|
|
110
|
+
# @return [void]
|
|
111
|
+
#
|
|
112
|
+
def log(event, payload)
|
|
113
|
+
logger = rails_logger
|
|
114
|
+
return if logger.nil?
|
|
115
|
+
|
|
116
|
+
logger.debug { "[PMS] #{EVENT_PREFIX}.#{event} #{log_fields(payload)}" }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Logger, nil]
|
|
120
|
+
def rails_logger
|
|
121
|
+
return unless defined?(Rails)
|
|
122
|
+
return unless Rails.respond_to?(:logger)
|
|
123
|
+
|
|
124
|
+
Rails.logger
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param payload [Hash]
|
|
128
|
+
#
|
|
129
|
+
# @return [String]
|
|
130
|
+
#
|
|
131
|
+
def log_fields(payload)
|
|
132
|
+
fields = []
|
|
133
|
+
fields << "user=#{payload[:user].inspect}" if payload.key?(:user)
|
|
134
|
+
payload.each do |key, value|
|
|
135
|
+
next if SKIPPED_LOG_KEYS.include?(key)
|
|
136
|
+
|
|
137
|
+
fields << "#{key}=#{value.inspect}"
|
|
138
|
+
end
|
|
139
|
+
fields.join(' ')
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Resolves linked issues from pull request payloads and finds their
|
|
6
|
+
# corresponding ProjectItems in the pipeline project.
|
|
7
|
+
#
|
|
8
|
+
# Uses +module_function+ pattern (matches +MetadataParser+, +Pipeline+).
|
|
9
|
+
#
|
|
10
|
+
module IssueLinker
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Regex matching GitHub closing keywords in PR bodies.
|
|
14
|
+
# Matches all nine variants: close/closes/closed, fix/fixes/fixed,
|
|
15
|
+
# resolve/resolves/resolved (case-insensitive).
|
|
16
|
+
CLOSING_KEYWORD_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/i
|
|
17
|
+
|
|
18
|
+
# Extracts issue numbers from a PR payload by parsing the PR body
|
|
19
|
+
# and commit messages for GitHub closing keywords (close/closes/closed,
|
|
20
|
+
# fix/fixes/fixed, resolve/resolves/resolved).
|
|
21
|
+
#
|
|
22
|
+
# @param pr_payload [Hash] parsed PR webhook payload (expects +:body+ or +"body"+ key)
|
|
23
|
+
# @param commit_messages [Array<String>] commit messages to scan (default: [])
|
|
24
|
+
#
|
|
25
|
+
# @return [Array<Integer>] referenced issue numbers (empty if none found)
|
|
26
|
+
#
|
|
27
|
+
def extract_issue_numbers(pr_payload, commit_messages: [])
|
|
28
|
+
texts = []
|
|
29
|
+
|
|
30
|
+
body = pr_payload[:body] || pr_payload['body']
|
|
31
|
+
texts << body if body.present?
|
|
32
|
+
|
|
33
|
+
Array.wrap(commit_messages).each do |message|
|
|
34
|
+
texts << message if message.present?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return [] if texts.empty?
|
|
38
|
+
|
|
39
|
+
matches = texts.join("\n").scan(CLOSING_KEYWORD_PATTERN).flatten
|
|
40
|
+
matches.map!(&:to_i)
|
|
41
|
+
matches.uniq!
|
|
42
|
+
matches
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Finds the ProjectItem in the pipeline project that matches the given
|
|
46
|
+
# issue number. Loads the full project and iterates its items.
|
|
47
|
+
#
|
|
48
|
+
# @param issue_number [Integer] issue number to find
|
|
49
|
+
# @param repo [Symbol, String, nil] unused, reserved for future multi-repo support
|
|
50
|
+
# @param project_number [Integer, nil] pipeline project number
|
|
51
|
+
#
|
|
52
|
+
# @return [PlanMyStuff::ProjectItem, nil] matching item, or nil if not found
|
|
53
|
+
#
|
|
54
|
+
def find_project_item(issue_number, _repo: nil, project_number: nil)
|
|
55
|
+
number = PlanMyStuff::Pipeline.resolve_pipeline_project_number(project_number)
|
|
56
|
+
project = PlanMyStuff::Project.find(number)
|
|
57
|
+
|
|
58
|
+
project.items.find { |item| item.number == issue_number }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Canonical pipeline status constants.
|
|
6
|
+
#
|
|
7
|
+
# Each constant holds the human-readable status name that maps to a
|
|
8
|
+
# GitHub Projects V2 single-select field value. Consuming apps can
|
|
9
|
+
# override display names via +PlanMyStuff.configuration.pipeline_statuses+
|
|
10
|
+
# but the canonical names here remain the internal identifiers.
|
|
11
|
+
#
|
|
12
|
+
module Status
|
|
13
|
+
SUBMITTED = 'Submitted'
|
|
14
|
+
STARTED = 'Started'
|
|
15
|
+
IN_REVIEW = 'In Review'
|
|
16
|
+
TESTING = 'Testing'
|
|
17
|
+
READY_FOR_RELEASE = 'Ready for Release'
|
|
18
|
+
RELEASE_IN_PROGRESS = 'Release in Progress'
|
|
19
|
+
COMPLETED = 'Completed'
|
|
20
|
+
|
|
21
|
+
# All statuses in pipeline order (frozen).
|
|
22
|
+
ALL = [
|
|
23
|
+
SUBMITTED,
|
|
24
|
+
STARTED,
|
|
25
|
+
IN_REVIEW,
|
|
26
|
+
TESTING,
|
|
27
|
+
READY_FOR_RELEASE,
|
|
28
|
+
RELEASE_IN_PROGRESS,
|
|
29
|
+
COMPLETED,
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# Converts a canonical status name to a snake_case key suitable for
|
|
33
|
+
# notification event names.
|
|
34
|
+
#
|
|
35
|
+
# @param canonical [String] one of the canonical status names
|
|
36
|
+
#
|
|
37
|
+
# @return [String] snake_case key (e.g. +"ready_for_release"+)
|
|
38
|
+
#
|
|
39
|
+
def self.key_for(canonical)
|
|
40
|
+
canonical.downcase.tr(' ', '_')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|