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/label.rb
CHANGED
|
@@ -1,46 +1,123 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PlanMyStuff
|
|
4
|
-
# Wraps a GitHub label with a reference to its parent issue.
|
|
5
|
-
#
|
|
4
|
+
# Wraps a GitHub label with a reference to its parent issue. Class methods provide the public API for add/remove
|
|
5
|
+
# 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
|
+
PlanMyStuff::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. Idempotent: a 404 from +label+ triggers
|
|
38
|
+
# creation; a 422 from +add_label+ (concurrent-creation race) is treated as success.
|
|
39
|
+
#
|
|
40
|
+
# @param repo [String, Symbol] repo name or key
|
|
41
|
+
# @param name [String] label name
|
|
42
|
+
# @param color [String] hex color without +#+
|
|
43
|
+
# @param description [String, nil]
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
#
|
|
47
|
+
def ensure!(repo:, name:, color: 'fbca04', description: nil)
|
|
48
|
+
client = PlanMyStuff.client
|
|
49
|
+
client.rest(:label, repo, name)
|
|
50
|
+
rescue PlanMyStuff::APIError => e
|
|
51
|
+
raise(e) unless e.status == 404
|
|
52
|
+
|
|
53
|
+
create_label!(client, repo, name, color, description)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns whether a label exists in the repo.
|
|
57
|
+
#
|
|
58
|
+
# @raise [PlanMyStuff::APIError] if the GET fails with any status other than 404
|
|
59
|
+
#
|
|
60
|
+
# @param repo [String, Symbol, PlanMyStuff::Repo] repo name or key
|
|
61
|
+
# @param name [String] label name
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
#
|
|
65
|
+
def exists?(repo:, name:, do_raise: true)
|
|
66
|
+
PlanMyStuff.client.rest(:label, repo, name)
|
|
67
|
+
true
|
|
68
|
+
rescue PlanMyStuff::APIError => e
|
|
69
|
+
raise(e) if do_raise && e.status != 404
|
|
70
|
+
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
28
74
|
# Removes labels from a GitHub issue.
|
|
29
75
|
#
|
|
30
76
|
# @param issue [PlanMyStuff::Issue] parent issue
|
|
31
77
|
# @param labels [Array<String>]
|
|
78
|
+
# @param user [Object, nil] actor for the notification event
|
|
32
79
|
#
|
|
33
80
|
# @return [Array<Array<PlanMyStuff::Label>>] results of each removal
|
|
34
81
|
#
|
|
35
|
-
def remove(issue:, labels:)
|
|
36
|
-
Array.wrap(labels)
|
|
82
|
+
def remove!(issue:, labels:, user: nil)
|
|
83
|
+
label_names = Array.wrap(labels)
|
|
84
|
+
|
|
85
|
+
results = label_names.map do |label|
|
|
37
86
|
result = PlanMyStuff.client.rest(:remove_label, issue.repo, issue.number, label)
|
|
38
87
|
result.map { |gh_label| build(gh_label, issue: issue) }
|
|
39
88
|
end
|
|
89
|
+
|
|
90
|
+
PlanMyStuff::Cache.delete_issue(issue.repo, issue.number)
|
|
91
|
+
|
|
92
|
+
PlanMyStuff::Notifications.instrument(
|
|
93
|
+
'label_removed', issue, user: user, labels: label_names,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
results
|
|
40
97
|
end
|
|
41
98
|
|
|
42
99
|
private
|
|
43
100
|
|
|
101
|
+
# Creates a label, tolerating the 422 "already exists" race that occurs when a concurrent ensure! slipped in
|
|
102
|
+
# between our 404 read and this write.
|
|
103
|
+
#
|
|
104
|
+
# @param client [PlanMyStuff::Client]
|
|
105
|
+
# @param repo [String, Symbol]
|
|
106
|
+
# @param name [String]
|
|
107
|
+
# @param color [String]
|
|
108
|
+
# @param description [String, nil]
|
|
109
|
+
#
|
|
110
|
+
# @return [void]
|
|
111
|
+
#
|
|
112
|
+
def create_label!(client, repo, name, color, description)
|
|
113
|
+
options = {}
|
|
114
|
+
options[:description] = description if description
|
|
115
|
+
|
|
116
|
+
client.rest(:add_label, repo, name, color, **options)
|
|
117
|
+
rescue PlanMyStuff::APIError => e
|
|
118
|
+
raise(e) unless e.status == 422
|
|
119
|
+
end
|
|
120
|
+
|
|
44
121
|
# Hydrates a Label from a GitHub API response.
|
|
45
122
|
#
|
|
46
123
|
# @param github_label [Object] Octokit label response
|
|
@@ -49,11 +126,21 @@ module PlanMyStuff
|
|
|
49
126
|
# @return [PlanMyStuff::Label]
|
|
50
127
|
#
|
|
51
128
|
def build(github_label, issue:)
|
|
52
|
-
|
|
53
|
-
label
|
|
54
|
-
label.
|
|
129
|
+
label = new(name: read_field(github_label, :name), issue: issue)
|
|
130
|
+
label.instance_variable_set(:@github_response, github_label)
|
|
131
|
+
label.__send__(:persisted!)
|
|
55
132
|
label
|
|
56
133
|
end
|
|
57
134
|
end
|
|
135
|
+
|
|
136
|
+
# Serializes the label to a JSON-safe hash, excluding the back-reference to the parent issue to prevent
|
|
137
|
+
# recursive serialization cycles.
|
|
138
|
+
#
|
|
139
|
+
# @return [Hash]
|
|
140
|
+
#
|
|
141
|
+
def as_json(options = {})
|
|
142
|
+
merged_except = Array.wrap(options[:except]) + ['issue']
|
|
143
|
+
super(options.merge(except: merged_except)).merge('issue_number' => issue&.number)
|
|
144
|
+
end
|
|
58
145
|
end
|
|
59
146
|
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. Built by +Issue#add_related!+,
|
|
7
|
+
# +#add_blocker!+, +#add_sub_issue!+, +#set_parent!+, and +#mark_duplicate!+; also returned from their +remove_*+
|
|
8
|
+
# counterparts so callers can render activity-log lines like +"Added parent issue: owner/repo#42"+.
|
|
9
|
+
#
|
|
10
|
+
# Metadata-backed types live in +IssueMetadata#links+; native types are routed through GitHub APIs and never
|
|
11
|
+
# persisted in our metadata.
|
|
12
|
+
class Link
|
|
13
|
+
METADATA_TYPES = %w[related].freeze
|
|
14
|
+
NATIVE_TYPES = %w[blocking blocked_by parent sub_ticket duplicate_of].freeze
|
|
15
|
+
ALL_TYPES = (METADATA_TYPES + NATIVE_TYPES).freeze
|
|
16
|
+
|
|
17
|
+
include ActiveModel::Model
|
|
18
|
+
include ActiveModel::Attributes
|
|
19
|
+
include ActiveModel::Serializers::JSON
|
|
20
|
+
|
|
21
|
+
# @return [String] one of ALL_TYPES
|
|
22
|
+
attribute :type, :string
|
|
23
|
+
# @return [Integer] target issue's GitHub issue number
|
|
24
|
+
attribute :issue_number, :integer
|
|
25
|
+
# @return [String] full "owner/name" path of the target issue's repo
|
|
26
|
+
attribute :repo, :string
|
|
27
|
+
|
|
28
|
+
validates :type, presence: true, inclusion: { in: ALL_TYPES }
|
|
29
|
+
validates :issue_number, presence: true, numericality: { greater_than: 0, only_integer: true }
|
|
30
|
+
validates :repo, presence: true
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Builds and validates a +Link+ from an +Issue+-like object, another +Link+, or a hash. +type:+ fills in when
|
|
34
|
+
# the input does not carry one; +source_repo+ fills in when the hash input does not carry one. Raises
|
|
35
|
+
# +ActiveModel::ValidationError+ on missing/invalid fields, +ArgumentError+ when +input+ is an unsupported
|
|
36
|
+
# shape.
|
|
37
|
+
#
|
|
38
|
+
# @param input [PlanMyStuff::Issue, PlanMyStuff::Link, Hash]
|
|
39
|
+
# @param type [String, Symbol, nil]
|
|
40
|
+
# @param source_repo [String, PlanMyStuff::Repo, nil]
|
|
41
|
+
#
|
|
42
|
+
# @return [PlanMyStuff::Link]
|
|
43
|
+
#
|
|
44
|
+
def build!(input, type: nil, source_repo: nil)
|
|
45
|
+
return input if input.is_a?(PlanMyStuff::Link)
|
|
46
|
+
|
|
47
|
+
link =
|
|
48
|
+
if input.is_a?(Hash)
|
|
49
|
+
build_from_hash(input, type: type, source_repo: source_repo)
|
|
50
|
+
else
|
|
51
|
+
build_from_issue_like!(input, type: type)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
link.validate!
|
|
55
|
+
link
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# @raise [ArgumentError] if input does not respond to #number and #repo
|
|
61
|
+
#
|
|
62
|
+
# @return [PlanMyStuff::Link]
|
|
63
|
+
#
|
|
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
|
|
@@ -5,8 +5,10 @@ require 'active_support/core_ext/hash/deep_merge'
|
|
|
5
5
|
module PlanMyStuff
|
|
6
6
|
module Markdown
|
|
7
7
|
class << self
|
|
8
|
-
# Renders markdown text to HTML using the configured renderer.
|
|
9
|
-
#
|
|
8
|
+
# Renders markdown text to HTML using the configured renderer. Per-call options are deep-merged on top of
|
|
9
|
+
# config.markdown_options.
|
|
10
|
+
#
|
|
11
|
+
# @raise [ArgumentError] if the configured renderer is not an option
|
|
10
12
|
#
|
|
11
13
|
# @param text [String] raw markdown text
|
|
12
14
|
# @param options [Hash] renderer-specific options (merged over config defaults)
|
|
@@ -22,11 +24,11 @@ module PlanMyStuff
|
|
|
22
24
|
|
|
23
25
|
case config.markdown_renderer
|
|
24
26
|
when :commonmarker
|
|
25
|
-
render_commonmarker(text, merged)
|
|
27
|
+
render_commonmarker!(text, merged)
|
|
26
28
|
when :redcarpet
|
|
27
|
-
render_redcarpet(text, merged)
|
|
29
|
+
render_redcarpet!(text, merged)
|
|
28
30
|
when nil
|
|
29
|
-
"<code>#{text}</code>"
|
|
31
|
+
"<code>#{ERB::Util.html_escape(text)}</code>"
|
|
30
32
|
else
|
|
31
33
|
raise(
|
|
32
34
|
ArgumentError,
|
|
@@ -37,12 +39,14 @@ module PlanMyStuff
|
|
|
37
39
|
|
|
38
40
|
private
|
|
39
41
|
|
|
42
|
+
# @raise [PlanMyStuff::Error] if commonmarker gem is not available
|
|
43
|
+
#
|
|
40
44
|
# @param text [String]
|
|
41
45
|
# @param options [Hash]
|
|
42
46
|
#
|
|
43
47
|
# @return [String]
|
|
44
48
|
#
|
|
45
|
-
def render_commonmarker(text, options)
|
|
49
|
+
def render_commonmarker!(text, options)
|
|
46
50
|
require('commonmarker') unless defined?(Commonmarker)
|
|
47
51
|
|
|
48
52
|
if options.empty?
|
|
@@ -58,12 +62,14 @@ module PlanMyStuff
|
|
|
58
62
|
)
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
# @raise [PlanMyStuff::Error] if redcarpet gem is not available
|
|
66
|
+
#
|
|
61
67
|
# @param text [String]
|
|
62
68
|
# @param options [Hash]
|
|
63
69
|
#
|
|
64
70
|
# @return [String]
|
|
65
71
|
#
|
|
66
|
-
def render_redcarpet(text, options)
|
|
72
|
+
def render_redcarpet!(text, options)
|
|
67
73
|
require('redcarpet') unless defined?(Redcarpet)
|
|
68
74
|
|
|
69
75
|
options = options.dup
|
|
@@ -4,7 +4,22 @@ require 'json'
|
|
|
4
4
|
|
|
5
5
|
module PlanMyStuff
|
|
6
6
|
module MetadataParser
|
|
7
|
-
|
|
7
|
+
# Collapsible <details> block containing a JSON code fence. Renders visibly on
|
|
8
|
+
# GitHub (issue #58) instead of being hidden in an HTML comment.
|
|
9
|
+
METADATA_PATTERN = %r{
|
|
10
|
+
\A<details><summary>pms-metadata</summary>\n\n
|
|
11
|
+
```json\n(.*?)\n```\n\n
|
|
12
|
+
</details>\n*
|
|
13
|
+
}mx
|
|
14
|
+
|
|
15
|
+
# Visible attachments block emitted after the metadata block when the
|
|
16
|
+
# metadata carries non-empty +attachments+ (issue #70). Parse strips it
|
|
17
|
+
# so round-trips stay clean.
|
|
18
|
+
ATTACHMENTS_PATTERN = %r{
|
|
19
|
+
\A<details><summary>attachments\ \(\d+\)</summary>\n\n
|
|
20
|
+
.*?\n\n
|
|
21
|
+
</details>\n*
|
|
22
|
+
}mx
|
|
8
23
|
|
|
9
24
|
module_function
|
|
10
25
|
|
|
@@ -16,12 +31,11 @@ module PlanMyStuff
|
|
|
16
31
|
#
|
|
17
32
|
def parse(raw_body)
|
|
18
33
|
return { metadata: {}, body: '' } if raw_body.blank?
|
|
34
|
+
return { metadata: {}, body: raw_body } unless raw_body.match?(METADATA_PATTERN)
|
|
19
35
|
|
|
20
36
|
match = raw_body.match(METADATA_PATTERN)
|
|
21
|
-
return { metadata: {}, body: raw_body } if match.nil?
|
|
22
|
-
|
|
23
37
|
metadata = JSON.parse(match[1], symbolize_names: true)
|
|
24
|
-
body = raw_body.sub(METADATA_PATTERN, '')
|
|
38
|
+
body = raw_body.sub(METADATA_PATTERN, '').sub(ATTACHMENTS_PATTERN, '')
|
|
25
39
|
|
|
26
40
|
{ metadata: metadata, body: body }
|
|
27
41
|
rescue JSON::ParserError
|
|
@@ -30,24 +44,49 @@ module PlanMyStuff
|
|
|
30
44
|
|
|
31
45
|
# Serializes a metadata hash and body into the stored format
|
|
32
46
|
#
|
|
47
|
+
# @raise [ArgumentError] if metadata is not a Hash or PlanMyStuff::CustomFields
|
|
48
|
+
#
|
|
33
49
|
# @param metadata [Hash, PlanMyStuff::CustomFields]
|
|
34
50
|
# @param body [String]
|
|
35
51
|
#
|
|
36
52
|
# @return [String]
|
|
37
53
|
#
|
|
38
|
-
def serialize(metadata, body)
|
|
54
|
+
def serialize!(metadata, body)
|
|
39
55
|
if !metadata.is_a?(Hash) && !metadata.is_a?(PlanMyStuff::CustomFields)
|
|
40
56
|
raise(ArgumentError, "metadata must be a Hash or PlanMyStuff::CustomFields, got #{metadata.class}")
|
|
41
57
|
end
|
|
42
58
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
hash = metadata.is_a?(PlanMyStuff::CustomFields) ? metadata.to_h : metadata
|
|
60
|
+
json = JSON.pretty_generate(hash)
|
|
61
|
+
|
|
62
|
+
"<details><summary>pms-metadata</summary>\n\n```json\n#{json}\n```\n\n</details>\n\n" \
|
|
63
|
+
"#{attachments_block(hash)}#{body}"
|
|
64
|
+
end
|
|
49
65
|
|
|
50
|
-
|
|
66
|
+
# Renders the visible attachments block when +metadata+ carries
|
|
67
|
+
# non-empty +:attachments+, otherwise returns an empty string.
|
|
68
|
+
#
|
|
69
|
+
# @param metadata [Hash]
|
|
70
|
+
#
|
|
71
|
+
# @return [String]
|
|
72
|
+
#
|
|
73
|
+
def attachments_block(metadata)
|
|
74
|
+
attachments = metadata[:attachments]
|
|
75
|
+
return '' if attachments.blank?
|
|
76
|
+
|
|
77
|
+
lines = attachments.map { |a| attachment_line(a) }.join("\n")
|
|
78
|
+
"<details><summary>attachments (#{attachments.size})</summary>\n\n#{lines}\n\n</details>\n\n"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param attachment [Hash{Symbol=>String}]
|
|
82
|
+
#
|
|
83
|
+
# @return [String]
|
|
84
|
+
#
|
|
85
|
+
def attachment_line(attachment)
|
|
86
|
+
url = "https://github.com/#{attachment[:owner]}/#{attachment[:repo]}" \
|
|
87
|
+
"/blob/#{attachment[:sha]}/#{attachment[:path]}"
|
|
88
|
+
safe_filename = attachment[:filename].to_s.gsub(']', '\]')
|
|
89
|
+
"- [#{safe_filename}](#{url})"
|
|
51
90
|
end
|
|
52
91
|
end
|
|
53
92
|
end
|
|
@@ -0,0 +1,148 @@
|
|
|
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 +<event>.plan_my_stuff+ namespace via
|
|
11
|
+
# +ActiveSupport::Notifications+ (Rails convention: event first,
|
|
12
|
+
# library last - matches +sql.active_record+, +deliver.action_mailer+).
|
|
13
|
+
# Subscribers run synchronously.
|
|
14
|
+
#
|
|
15
|
+
module Notifications
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
EVENT_SUFFIX = 'plan_my_stuff'
|
|
19
|
+
SKIPPED_LOG_KEYS = %i[user timestamp visibility visibility_allowlist].freeze
|
|
20
|
+
|
|
21
|
+
# Fires +<event>.plan_my_stuff+ with a normalized payload.
|
|
22
|
+
#
|
|
23
|
+
# @param event [String] e.g. +'issue_created'+
|
|
24
|
+
# @param resource [Object] domain object (+Issue+, +Comment+, +ProjectItem+, ...), or an +Array+ of resources for a
|
|
25
|
+
# batch event (keyed by the pluralized element key, e.g. +:project_items+)
|
|
26
|
+
# @param user [Object, nil] explicit actor; falls back to +config.current_user+
|
|
27
|
+
# @param extra [Hash] additional payload entries (+changes:+, +labels:+, +user_ids:+, ...)
|
|
28
|
+
#
|
|
29
|
+
# @return [void]
|
|
30
|
+
#
|
|
31
|
+
def instrument(event, resource, user: nil, **extra)
|
|
32
|
+
actor = user || resolve_current_user
|
|
33
|
+
payload = build_payload(resource, actor, extra)
|
|
34
|
+
log(event, payload)
|
|
35
|
+
ActiveSupport::Notifications.instrument("#{event}.#{EVENT_SUFFIX}", payload)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Invokes +config.current_user+ if it responds to +call+.
|
|
39
|
+
#
|
|
40
|
+
# @return [Object, nil]
|
|
41
|
+
#
|
|
42
|
+
def resolve_current_user
|
|
43
|
+
resolver = PlanMyStuff.configuration.current_user
|
|
44
|
+
return if resolver.nil?
|
|
45
|
+
|
|
46
|
+
resolver.respond_to?(:call) ? resolver.call : resolver
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Builds the payload hash for an event.
|
|
50
|
+
#
|
|
51
|
+
# @param resource [Object]
|
|
52
|
+
# @param actor [Object, nil]
|
|
53
|
+
# @param extra [Hash]
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash]
|
|
56
|
+
#
|
|
57
|
+
def build_payload(resource, actor, extra)
|
|
58
|
+
payload = {
|
|
59
|
+
infer_resource_key(resource) => resource,
|
|
60
|
+
:user => actor,
|
|
61
|
+
:timestamp => Time.current,
|
|
62
|
+
}
|
|
63
|
+
payload.merge!(visibility_fields(resource))
|
|
64
|
+
payload.merge(extra)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Maps a resource object to its payload key. An +Array+ recurses on its first element and pluralizes that key, so
|
|
68
|
+
# batch events carry the full set under one key (a batch of project items keys as +:project_items+, a batch of
|
|
69
|
+
# issues as +:issues+, an empty/unknown batch as +:resources+).
|
|
70
|
+
#
|
|
71
|
+
# @param resource [Object]
|
|
72
|
+
#
|
|
73
|
+
# @return [Symbol]
|
|
74
|
+
#
|
|
75
|
+
def infer_resource_key(resource)
|
|
76
|
+
case resource
|
|
77
|
+
when PlanMyStuff::Issue then :issue
|
|
78
|
+
when PlanMyStuff::Comment then :comment
|
|
79
|
+
when PlanMyStuff::BaseProjectItem then :project_item
|
|
80
|
+
when Array then :"#{infer_resource_key(resource.first)}s"
|
|
81
|
+
else :resource
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Extracts visibility + allowlist from +Issue+/+Comment+ resources.
|
|
86
|
+
# Returns an empty hash for resources without visibility.
|
|
87
|
+
#
|
|
88
|
+
# @param resource [Object]
|
|
89
|
+
#
|
|
90
|
+
# @return [Hash]
|
|
91
|
+
#
|
|
92
|
+
def visibility_fields(resource)
|
|
93
|
+
case resource
|
|
94
|
+
when PlanMyStuff::Issue
|
|
95
|
+
{
|
|
96
|
+
visibility: resource.metadata.visibility,
|
|
97
|
+
visibility_allowlist: Array.wrap(resource.metadata.visibility_allowlist),
|
|
98
|
+
}
|
|
99
|
+
when PlanMyStuff::Comment
|
|
100
|
+
parent_allowlist = resource.issue ? resource.issue.metadata.visibility_allowlist : []
|
|
101
|
+
{
|
|
102
|
+
visibility: resource.visibility&.to_s,
|
|
103
|
+
visibility_allowlist: Array.wrap(parent_allowlist),
|
|
104
|
+
}
|
|
105
|
+
else
|
|
106
|
+
{}
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Emits a debug log line for the event. No-op when no logger is
|
|
111
|
+
# available (e.g. outside Rails).
|
|
112
|
+
#
|
|
113
|
+
# @param event [String]
|
|
114
|
+
# @param payload [Hash]
|
|
115
|
+
#
|
|
116
|
+
# @return [void]
|
|
117
|
+
#
|
|
118
|
+
def log(event, payload)
|
|
119
|
+
logger = rails_logger
|
|
120
|
+
return if logger.nil?
|
|
121
|
+
|
|
122
|
+
logger.debug { "[PlanMyStuff] #{event}.#{EVENT_SUFFIX} #{log_fields(payload)}" }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [Logger, nil]
|
|
126
|
+
def rails_logger
|
|
127
|
+
return unless defined?(Rails)
|
|
128
|
+
return unless Rails.respond_to?(:logger)
|
|
129
|
+
|
|
130
|
+
Rails.logger
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param payload [Hash]
|
|
134
|
+
#
|
|
135
|
+
# @return [String]
|
|
136
|
+
#
|
|
137
|
+
def log_fields(payload)
|
|
138
|
+
fields = []
|
|
139
|
+
fields << "user=#{payload[:user].inspect}" if payload.key?(:user)
|
|
140
|
+
payload.each do |key, value|
|
|
141
|
+
next if SKIPPED_LOG_KEYS.include?(key)
|
|
142
|
+
|
|
143
|
+
fields << "#{key}=#{value.inspect}"
|
|
144
|
+
end
|
|
145
|
+
fields.join(' ')
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Removes pipeline project items that have been at +Completed+ for
|
|
6
|
+
# longer than the configured TTL.
|
|
7
|
+
#
|
|
8
|
+
# Driven by +RemindersSweepJob+ so the gem ships one scheduled
|
|
9
|
+
# entrypoint. Items are removed via +Pipeline.remove!+ so subscribers
|
|
10
|
+
# see the standard +pipeline_removed.plan_my_stuff+ event.
|
|
11
|
+
#
|
|
12
|
+
module CompletedSweep
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Runs the sweep. No-op when
|
|
16
|
+
# +configuration.pipeline_completion_purge_enabled+ is false.
|
|
17
|
+
#
|
|
18
|
+
# @param project_number [Integer, nil]
|
|
19
|
+
# @param now [Time]
|
|
20
|
+
#
|
|
21
|
+
# @return [Array<PlanMyStuff::ProjectItem>] removed items
|
|
22
|
+
#
|
|
23
|
+
def perform!(project_number: nil, now: Time.current)
|
|
24
|
+
config = PlanMyStuff.configuration
|
|
25
|
+
return [] unless config.pipeline_completion_purge_enabled
|
|
26
|
+
|
|
27
|
+
number = PlanMyStuff::Pipeline.resolve_pipeline_project_number!(project_number)
|
|
28
|
+
project = PlanMyStuff::Project.find(number)
|
|
29
|
+
|
|
30
|
+
completed_status = PlanMyStuff::Pipeline.resolve_status_name(
|
|
31
|
+
PlanMyStuff::Pipeline::Status::COMPLETED,
|
|
32
|
+
)
|
|
33
|
+
ttl = config.pipeline_completion_ttl_hours.hours
|
|
34
|
+
|
|
35
|
+
candidates = project.items.select do |item|
|
|
36
|
+
item.status == completed_status &&
|
|
37
|
+
item.updated_at.present? &&
|
|
38
|
+
item.updated_at + ttl < now
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
candidates.each { |item| PlanMyStuff::Pipeline.remove!(item) }
|
|
42
|
+
candidates
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|