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
|
@@ -7,10 +7,115 @@ module PlanMyStuff
|
|
|
7
7
|
# Provides shared persistence predicates and utility helpers.
|
|
8
8
|
class ApplicationRecord
|
|
9
9
|
include ActiveModel::Model
|
|
10
|
+
include ActiveModel::Attributes
|
|
11
|
+
include ActiveModel::Dirty
|
|
12
|
+
include ActiveModel::Serializers::JSON
|
|
13
|
+
|
|
14
|
+
# @return [Object, nil] raw GitHub API response this record was hydrated from.
|
|
15
|
+
# Escape hatch for consuming apps to access fields the gem doesn't expose.
|
|
16
|
+
attr_reader :github_response
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Reads a field from an object that may respond to method calls or hash access.
|
|
20
|
+
#
|
|
21
|
+
# @param obj [Object]
|
|
22
|
+
# @param field [Symbol]
|
|
23
|
+
#
|
|
24
|
+
# @return [Object]
|
|
25
|
+
#
|
|
26
|
+
def read_field(obj, field)
|
|
27
|
+
obj.respond_to?(field) ? obj.public_send(field) : obj[field]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# @param client [PlanMyStuff::Client]
|
|
33
|
+
#
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
#
|
|
36
|
+
def not_modified?(client)
|
|
37
|
+
response = client.last_response
|
|
38
|
+
return false if response.nil?
|
|
39
|
+
|
|
40
|
+
response.status == 304
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Captures the +ETag+ header from the most recent REST response and
|
|
44
|
+
# forwards it to +cache_writer+.
|
|
45
|
+
#
|
|
46
|
+
# @param client [PlanMyStuff::Client]
|
|
47
|
+
# @param repo [String]
|
|
48
|
+
# @param id [Integer, nil]
|
|
49
|
+
# @param body [Object] parsed GitHub response
|
|
50
|
+
# @param cache_writer [Symbol] +PlanMyStuff::Cache+ method name, e.g. +:write_issue+
|
|
51
|
+
#
|
|
52
|
+
# @return [void]
|
|
53
|
+
#
|
|
54
|
+
def store_etag_to_cache(client, repo, id, body, cache_writer:)
|
|
55
|
+
return if id.nil?
|
|
56
|
+
|
|
57
|
+
response = client.last_response
|
|
58
|
+
return if response.nil?
|
|
59
|
+
|
|
60
|
+
etag = response.headers && response.headers['etag']
|
|
61
|
+
return if etag.blank?
|
|
62
|
+
|
|
63
|
+
PlanMyStuff::Cache.public_send(cache_writer, repo, id, etag: etag, body: body)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Captures the +ETag+ header from the most recent REST response and
|
|
67
|
+
# forwards it to +Cache.write_list+.
|
|
68
|
+
#
|
|
69
|
+
# @param client [PlanMyStuff::Client]
|
|
70
|
+
# @param resource [Symbol] :issue or :comment
|
|
71
|
+
# @param repo [String]
|
|
72
|
+
# @param params [Hash] query params that identify this list
|
|
73
|
+
# @param body [Object] parsed GitHub list response
|
|
74
|
+
#
|
|
75
|
+
# @return [void]
|
|
76
|
+
#
|
|
77
|
+
def store_list_etag_to_cache(client, resource, repo, params, body)
|
|
78
|
+
response = client.last_response
|
|
79
|
+
return if response.nil?
|
|
80
|
+
|
|
81
|
+
etag = response.headers && response.headers['etag']
|
|
82
|
+
return if etag.blank?
|
|
83
|
+
|
|
84
|
+
PlanMyStuff::Cache.write_list(resource, repo, params, etag: etag, body: body)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Reads a REST resource through the ETag cache.
|
|
88
|
+
#
|
|
89
|
+
# On a cache hit sends +If-None-Match+; a 304 returns the cached
|
|
90
|
+
# body. A 200 stores the new ETag via +cache_writer+ and returns
|
|
91
|
+
# the fresh result.
|
|
92
|
+
#
|
|
93
|
+
# @param client [PlanMyStuff::Client]
|
|
94
|
+
# @param repo [String]
|
|
95
|
+
# @param id [Integer]
|
|
96
|
+
# @param rest_method [Symbol] Octokit method name
|
|
97
|
+
# @param cache_reader [Symbol] +PlanMyStuff::Cache+ method name, e.g. +:read_issue+
|
|
98
|
+
# @param cache_writer [Symbol] +PlanMyStuff::Cache+ method name, e.g. +:write_issue+
|
|
99
|
+
#
|
|
100
|
+
# @return [Object] parsed GitHub response
|
|
101
|
+
#
|
|
102
|
+
def fetch_with_etag_cache(client, repo, id, rest_method:, cache_reader:, cache_writer:)
|
|
103
|
+
cached = PlanMyStuff::Cache.public_send(cache_reader, repo, id)
|
|
104
|
+
options = cached ? { headers: { 'If-None-Match' => cached[:etag] } } : {}
|
|
105
|
+
|
|
106
|
+
result = client.rest(rest_method, repo, id, **options)
|
|
107
|
+
|
|
108
|
+
return cached[:body] if cached && not_modified?(client)
|
|
109
|
+
|
|
110
|
+
store_etag_to_cache(client, repo, id, result, cache_writer: cache_writer)
|
|
111
|
+
result
|
|
112
|
+
end
|
|
113
|
+
end
|
|
10
114
|
|
|
11
115
|
def initialize(**)
|
|
12
116
|
super
|
|
13
117
|
@persisted = false
|
|
118
|
+
@destroyed = false
|
|
14
119
|
end
|
|
15
120
|
|
|
16
121
|
# @return [Boolean]
|
|
@@ -23,8 +128,37 @@ module PlanMyStuff
|
|
|
23
128
|
!@persisted
|
|
24
129
|
end
|
|
25
130
|
|
|
131
|
+
# @return [Boolean]
|
|
132
|
+
def destroyed?
|
|
133
|
+
@destroyed
|
|
134
|
+
end
|
|
135
|
+
|
|
26
136
|
private
|
|
27
137
|
|
|
138
|
+
# Marks this record as persisted. Subclasses call this after a
|
|
139
|
+
# successful create/find/update against the underlying GitHub
|
|
140
|
+
# resource. Also applies any pending dirty changes so the record
|
|
141
|
+
# is clean after hydration and +#previous_changes+ reflects the
|
|
142
|
+
# values that were just loaded.
|
|
143
|
+
#
|
|
144
|
+
# @return [void]
|
|
145
|
+
#
|
|
146
|
+
def persisted!
|
|
147
|
+
@persisted = true
|
|
148
|
+
changes_applied
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Marks this record as destroyed. Subclasses call this from their
|
|
152
|
+
# +destroy!+ implementations after the underlying remote resource
|
|
153
|
+
# has been deleted.
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
156
|
+
#
|
|
157
|
+
def destroyed!
|
|
158
|
+
@destroyed = true
|
|
159
|
+
@persisted = false
|
|
160
|
+
end
|
|
161
|
+
|
|
28
162
|
# Reads a field from an object that may respond to method calls or hash access.
|
|
29
163
|
#
|
|
30
164
|
# @param obj [Object]
|
|
@@ -33,7 +167,30 @@ module PlanMyStuff
|
|
|
33
167
|
# @return [Object]
|
|
34
168
|
#
|
|
35
169
|
def read_field(obj, field)
|
|
36
|
-
|
|
170
|
+
self.class.read_field(obj, field)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Reads a field from an object, returning nil if the field does not exist.
|
|
174
|
+
#
|
|
175
|
+
# @param obj [Object]
|
|
176
|
+
# @param field [Symbol]
|
|
177
|
+
#
|
|
178
|
+
# @return [Object, nil]
|
|
179
|
+
#
|
|
180
|
+
def safe_read_field(obj, field)
|
|
181
|
+
read_field(obj, field)
|
|
182
|
+
rescue NameError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @return [Time, nil]
|
|
187
|
+
def parse_github_time(value)
|
|
188
|
+
return if value.nil?
|
|
189
|
+
return value.utc if value.is_a?(Time)
|
|
190
|
+
|
|
191
|
+
Time.parse(value.to_s).utc
|
|
192
|
+
rescue ArgumentError
|
|
193
|
+
nil
|
|
37
194
|
end
|
|
38
195
|
end
|
|
39
196
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
|
|
5
|
+
module PlanMyStuff
|
|
6
|
+
# Value object representing a single manager approval on an +Issue+.
|
|
7
|
+
# Persisted in +IssueMetadata#approvals+ and returned from
|
|
8
|
+
# +Issue.request_approvals!+, +Issue.approve!+, +Issue.reject!+, and
|
|
9
|
+
# +Issue.revoke_approval!+.
|
|
10
|
+
#
|
|
11
|
+
# Mirrors +PlanMyStuff::Link+: +ActiveModel::Attributes+-backed, with
|
|
12
|
+
# +Serializers::JSON+ for round-trip through the metadata blob.
|
|
13
|
+
#
|
|
14
|
+
class Approval
|
|
15
|
+
STATUSES = %w[pending approved rejected].freeze
|
|
16
|
+
|
|
17
|
+
include ActiveModel::Model
|
|
18
|
+
include ActiveModel::Attributes
|
|
19
|
+
include ActiveModel::Serializers::JSON
|
|
20
|
+
|
|
21
|
+
# @return [Integer] app-side user id of the required approver
|
|
22
|
+
attribute :user_id, :integer
|
|
23
|
+
# @return [String] +"pending"+, +"approved"+, or +"rejected"+
|
|
24
|
+
attribute :status, :string, default: 'pending'
|
|
25
|
+
# @return [DateTime, nil] timestamp when status flipped to +"approved"+
|
|
26
|
+
attribute :approved_at, :datetime
|
|
27
|
+
# @return [DateTime, nil] timestamp when status flipped to +"rejected"+
|
|
28
|
+
attribute :rejected_at, :datetime
|
|
29
|
+
|
|
30
|
+
validates :user_id, presence: true, numericality: { greater_than: 0, only_integer: true }
|
|
31
|
+
validates :status, inclusion: { in: STATUSES }
|
|
32
|
+
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def pending?
|
|
35
|
+
status == 'pending'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def approved?
|
|
40
|
+
status == 'approved'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
def rejected?
|
|
45
|
+
status == 'rejected'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Lazy-resolves the app-side user for this approval.
|
|
49
|
+
# Not memoized -- +PlanMyStuff::UserResolver+ owns caching.
|
|
50
|
+
#
|
|
51
|
+
# @return [Object, nil]
|
|
52
|
+
#
|
|
53
|
+
def user
|
|
54
|
+
PlanMyStuff::UserResolver.resolve(user_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Hash]
|
|
58
|
+
def to_h
|
|
59
|
+
{
|
|
60
|
+
user_id: user_id,
|
|
61
|
+
status: status,
|
|
62
|
+
approved_at: PlanMyStuff.format_time(approved_at),
|
|
63
|
+
rejected_at: PlanMyStuff.format_time(rejected_at),
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Two approvals are equal when they track the same user AND carry the
|
|
68
|
+
# same status. A pending and an approved record for the same user are
|
|
69
|
+
# NOT equal -- matters for set arithmetic during state transitions.
|
|
70
|
+
#
|
|
71
|
+
# @param other [Object]
|
|
72
|
+
#
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
#
|
|
75
|
+
def ==(other)
|
|
76
|
+
return false unless other.is_a?(PlanMyStuff::Approval)
|
|
77
|
+
|
|
78
|
+
user_id == other.user_id && status == other.status
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
alias eql? ==
|
|
82
|
+
|
|
83
|
+
# @return [Integer]
|
|
84
|
+
def hash
|
|
85
|
+
[user_id, status].hash
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
module Archive
|
|
5
|
+
# Walks a single repo's closed issues, archiving those that have
|
|
6
|
+
# aged past +config.archive_closed_after_days+. Excludes issues
|
|
7
|
+
# auto-closed by +Reminders::Closer+ (+metadata.closed_by_inactivity+)
|
|
8
|
+
# and any that are already archived (marker timestamp or label).
|
|
9
|
+
#
|
|
10
|
+
# Paginates +Issue.list(state: :closed)+ until either an empty page
|
|
11
|
+
# or the hard +MAX_PAGES+ cap. GitHub's +list_issues+ returns in
|
|
12
|
+
# created-desc order, so closed_at is not monotonic across pages;
|
|
13
|
+
# we can't short-circuit on "this page is all within cutoff." Each
|
|
14
|
+
# subsequent sweep skips already-archived issues cheaply via
|
|
15
|
+
# +skip?+, so the steady-state walk is bounded in practice.
|
|
16
|
+
class Sweep
|
|
17
|
+
PAGE_SIZE = 50
|
|
18
|
+
MAX_PAGES = 20
|
|
19
|
+
|
|
20
|
+
# @param repo [Symbol, String] repo key or full name
|
|
21
|
+
# @param now [Time] clock reference
|
|
22
|
+
#
|
|
23
|
+
def initialize(repo:, now: Time.now.utc)
|
|
24
|
+
@repo = repo
|
|
25
|
+
@now = now.utc
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Runs the sweep. No-op when +config.archiving_enabled+ is false.
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
#
|
|
32
|
+
def call
|
|
33
|
+
return unless PlanMyStuff.configuration.archiving_enabled
|
|
34
|
+
|
|
35
|
+
(1..MAX_PAGES).each do |page|
|
|
36
|
+
issues = PlanMyStuff::Issue.list(
|
|
37
|
+
repo: @repo,
|
|
38
|
+
state: :closed,
|
|
39
|
+
page: page,
|
|
40
|
+
per_page: PAGE_SIZE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
break if issues.empty?
|
|
44
|
+
|
|
45
|
+
process(issues)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Per-issue exceptions are swallowed so a single bad archive
|
|
52
|
+
# doesn't halt the whole sweep.
|
|
53
|
+
#
|
|
54
|
+
# @param issues [Array<PlanMyStuff::Issue>]
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
#
|
|
58
|
+
def process(issues)
|
|
59
|
+
issues.each do |issue|
|
|
60
|
+
next if skip?(issue)
|
|
61
|
+
|
|
62
|
+
issue.archive!(now: @now)
|
|
63
|
+
rescue => e
|
|
64
|
+
warn("[PlanMyStuff::Archive::Sweep] #{issue.repo}##{issue.number} failed: #{e.class}: #{e.message}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param issue [PlanMyStuff::Issue]
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
#
|
|
72
|
+
def skip?(issue)
|
|
73
|
+
return true unless issue.pms_issue?
|
|
74
|
+
|
|
75
|
+
return true if issue.metadata.closed_by_inactivity
|
|
76
|
+
return true if issue.metadata.archived_at.present?
|
|
77
|
+
return true if issue.labels.include?(PlanMyStuff.configuration.archived_label)
|
|
78
|
+
return true if issue.closed_at.nil?
|
|
79
|
+
|
|
80
|
+
age_days = (@now - issue.closed_at.utc) / 1.day
|
|
81
|
+
age_days < PlanMyStuff.configuration.archive_closed_after_days
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanMyStuff
|
|
4
|
+
# Auto-archive engine. The +Sweep+ class walks closed issues in a repo
|
|
5
|
+
# and delegates to +Issue#archive!+ for each issue whose +closed_at+
|
|
6
|
+
# has aged past +config.archive_closed_after_days+.
|
|
7
|
+
#
|
|
8
|
+
# Entry point for the sweep lives in +RemindersSweepJob+; this module
|
|
9
|
+
# holds the POROs so they can be unit-tested without ActiveJob.
|
|
10
|
+
module Archive
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
module PlanMyStuff
|
|
7
|
+
# Value object representing a single attachment record on a +Comment+.
|
|
8
|
+
# Persisted in +CommentMetadata#attachments+; the gem owns the upload
|
|
9
|
+
# (see +PlanMyStuff::AttachmentUploader+) and stores the structured
|
|
10
|
+
# location (+owner+/+repo+/+sha+/+path+) of the uploaded file so the
|
|
11
|
+
# blob remains addressable across later branch rewrites.
|
|
12
|
+
#
|
|
13
|
+
# Mirrors +PlanMyStuff::Link+ / +PlanMyStuff::Approval+:
|
|
14
|
+
# +ActiveModel::Attributes+-backed, with +Serializers::JSON+ for
|
|
15
|
+
# round-trip through the metadata blob.
|
|
16
|
+
#
|
|
17
|
+
class Attachment
|
|
18
|
+
include ActiveModel::Model
|
|
19
|
+
include ActiveModel::Attributes
|
|
20
|
+
include ActiveModel::Serializers::JSON
|
|
21
|
+
|
|
22
|
+
# @return [String] display filename (user-provided original name)
|
|
23
|
+
attribute :filename, :string
|
|
24
|
+
# @return [String] owner of the attachment repo (e.g. +"BrandsInsurance"+)
|
|
25
|
+
attribute :owner, :string
|
|
26
|
+
# @return [String] attachment repo name
|
|
27
|
+
attribute :repo, :string
|
|
28
|
+
# @return [String] commit SHA pinning the file
|
|
29
|
+
attribute :sha, :string
|
|
30
|
+
# @return [String] path within the attachment repo (no leading slash)
|
|
31
|
+
attribute :path, :string
|
|
32
|
+
|
|
33
|
+
validates :filename, :owner, :repo, :sha, :path, presence: true
|
|
34
|
+
|
|
35
|
+
# @return [String] blob-viewer URL for the pinned file
|
|
36
|
+
def url
|
|
37
|
+
"https://github.com/#{owner}/#{repo}/blob/#{sha}/#{path}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash]
|
|
41
|
+
def to_h
|
|
42
|
+
{ filename: filename, owner: owner, repo: repo, sha: sha, path: path }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param other [Object]
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
#
|
|
49
|
+
def ==(other)
|
|
50
|
+
return false unless other.is_a?(PlanMyStuff::Attachment)
|
|
51
|
+
|
|
52
|
+
filename == other.filename && url == other.url
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
alias eql? ==
|
|
56
|
+
|
|
57
|
+
# @return [Integer]
|
|
58
|
+
def hash
|
|
59
|
+
[filename, url].hash
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Downloads the file via the GitHub Contents API (so it works on private repos) and writes the bytes to disk.
|
|
63
|
+
# Defaults the destination to +Dir.tmpdir+ joined with +File.basename(filename)+ (directory components
|
|
64
|
+
# stripped to prevent path traversal).
|
|
65
|
+
#
|
|
66
|
+
# @param dest [String, nil] destination path; defaults to +File.join(Dir.tmpdir, File.basename(filename))+
|
|
67
|
+
#
|
|
68
|
+
# @return [String] the path the bytes were written to
|
|
69
|
+
#
|
|
70
|
+
def download_to(dest = nil)
|
|
71
|
+
dest ||= File.join(Dir.tmpdir, File.basename(filename.to_s))
|
|
72
|
+
body = PlanMyStuff.client.rest(
|
|
73
|
+
:contents,
|
|
74
|
+
"#{owner}/#{repo}",
|
|
75
|
+
path: path,
|
|
76
|
+
ref: sha,
|
|
77
|
+
accept: 'application/vnd.github.raw',
|
|
78
|
+
)
|
|
79
|
+
File.binwrite(dest, body)
|
|
80
|
+
dest
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module PlanMyStuff
|
|
8
|
+
# Uploads attachment files to a shared attachment repo (+config.attachment_repo+ under +config.organization+) using
|
|
9
|
+
# GitHub's Git Data API, returning +PlanMyStuff::Attachment+ instances populated with the structured
|
|
10
|
+
# +owner+/+repo+/+sha+/+path+ location of each uploaded file.
|
|
11
|
+
#
|
|
12
|
+
# One commit per batch (atomic): all files in a single +upload_all!+ call land in the same tree/commit so partial
|
|
13
|
+
# failures cannot leave +config.main_branch+ in an inconsistent state.
|
|
14
|
+
#
|
|
15
|
+
# The attachment repo is assumed to exist; the uploader does not create it. Per-source-repo attachments are namespaced
|
|
16
|
+
# under +<repo_key>/issue-<number>/<uuid>.<ext>+, where +repo_key+ falls back to +repo.name+ for repos resolved
|
|
17
|
+
# without a +config.repos+ entry.
|
|
18
|
+
#
|
|
19
|
+
# @see PlanMyStuff::Attachment
|
|
20
|
+
#
|
|
21
|
+
class AttachmentUploader
|
|
22
|
+
class << self
|
|
23
|
+
# Uploads each entry in +files+ to the attachment repo and returns the resulting +Attachment+ instances. Order is
|
|
24
|
+
# not preserved: passthrough +Attachment+ entries are returned before newly-uploaded ones. +Attachment+ entries
|
|
25
|
+
# are passed through untouched (no upload), supporting round-trip through +Comment#save!+ without
|
|
26
|
+
# double-uploading.
|
|
27
|
+
#
|
|
28
|
+
# Empty / nil +files+ short-circuits with no API calls.
|
|
29
|
+
#
|
|
30
|
+
# @param repo [PlanMyStuff::Repo] source issues repo (used to namespace the path)
|
|
31
|
+
# @param issue_number [Integer]
|
|
32
|
+
# @param files [Array] mix of already-uploaded +PlanMyStuff::Attachment+ instances, uploaded-file objects
|
|
33
|
+
# responding to +#path+ and +#original_filename+ (e.g. Rails +ActionDispatch::Http::UploadedFile+), and
|
|
34
|
+
# String/Pathname paths to local files
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<PlanMyStuff::Attachment>]
|
|
37
|
+
#
|
|
38
|
+
def upload_all!(repo:, issue_number:, files:)
|
|
39
|
+
entries = Array.wrap(files)
|
|
40
|
+
return [] if entries.empty?
|
|
41
|
+
|
|
42
|
+
slots = entries.map { |entry| classify(entry) }
|
|
43
|
+
attachments = slots.grep(PlanMyStuff::Attachment)
|
|
44
|
+
pending = slots.grep(Hash)
|
|
45
|
+
|
|
46
|
+
return attachments if pending.blank?
|
|
47
|
+
|
|
48
|
+
commit_sha = create_commit!(repo: repo, issue_number: issue_number, pending: pending)
|
|
49
|
+
|
|
50
|
+
pending.each do |slot|
|
|
51
|
+
slot[:attachment] = build_attachment(commit_sha: commit_sha, slot: slot)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
publish_commit!(commit_sha)
|
|
55
|
+
|
|
56
|
+
attachments + pending.pluck(:attachment)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Normalizes a single input entry into a slot Hash or itself (an existing Attachment);
|
|
62
|
+
# pending slots carry +{filename:, content:}+ awaiting upload.
|
|
63
|
+
#
|
|
64
|
+
# @param entry [Object]
|
|
65
|
+
#
|
|
66
|
+
# @return [PlanMyStuff::Attachment, Hash]
|
|
67
|
+
#
|
|
68
|
+
def classify(entry)
|
|
69
|
+
if entry.is_a?(PlanMyStuff::Attachment)
|
|
70
|
+
entry
|
|
71
|
+
else
|
|
72
|
+
filename, content = extract_filename_and_content(entry)
|
|
73
|
+
{ filename: filename, content: content }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param entry [Object]
|
|
78
|
+
#
|
|
79
|
+
# @return [Array(String, String)] filename, raw bytes
|
|
80
|
+
#
|
|
81
|
+
def extract_filename_and_content(entry)
|
|
82
|
+
if entry.respond_to?(:path) && entry.respond_to?(:original_filename)
|
|
83
|
+
[leaf_filename(entry.original_filename), File.binread(entry.path)]
|
|
84
|
+
elsif entry.is_a?(String) || entry.is_a?(Pathname)
|
|
85
|
+
[File.basename(entry), File.binread(entry)]
|
|
86
|
+
else
|
|
87
|
+
raise(ArgumentError, "Unsupported attachment entry: #{entry.inspect}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Strips directory components (both POSIX and Windows separators) from a browser-supplied filename so
|
|
92
|
+
# values like +"../../secret.txt"+ or +"C:\\fakepath\\photo.jpg"+ cannot leak into stored metadata or
|
|
93
|
+
# later download paths.
|
|
94
|
+
#
|
|
95
|
+
# @param raw [Object] +original_filename+ from an uploaded-file-like object
|
|
96
|
+
#
|
|
97
|
+
# @return [String]
|
|
98
|
+
#
|
|
99
|
+
def leaf_filename(raw)
|
|
100
|
+
name = raw.to_s.tr('\\', '/').split('/').last
|
|
101
|
+
raise(ArgumentError, 'Attachment filename cannot be blank') if name.blank?
|
|
102
|
+
|
|
103
|
+
name
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns the path-segment used to namespace this source repo's attachments inside the shared attachment repo.
|
|
107
|
+
# Prefers +repo.key+ (registered repos); falls back to +repo.name+ for repos resolved by full name only.
|
|
108
|
+
#
|
|
109
|
+
# @param repo [PlanMyStuff::Repo]
|
|
110
|
+
#
|
|
111
|
+
# @return [String]
|
|
112
|
+
#
|
|
113
|
+
def namespace_for(repo)
|
|
114
|
+
(repo.key || repo.name).to_s
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Performs the Git Data API dance against the attachment repo and returns the new commit SHA without
|
|
118
|
+
# publishing it (the ref still points at the old head). Callers must invoke +publish_commit!+ after the
|
|
119
|
+
# batch has been converted into +Attachment+ instances so a failure during conversion leaves
|
|
120
|
+
# +config.main_branch+ untouched. Assumes the attachment repo and +config.main_branch+ exist.
|
|
121
|
+
#
|
|
122
|
+
# @param repo [PlanMyStuff::Repo] source issues repo
|
|
123
|
+
# @param issue_number [Integer]
|
|
124
|
+
# @param pending [Array<Hash>] slots awaiting upload
|
|
125
|
+
#
|
|
126
|
+
# @return [String] new commit SHA
|
|
127
|
+
#
|
|
128
|
+
def create_commit!(repo:, issue_number:, pending:)
|
|
129
|
+
client = PlanMyStuff.client
|
|
130
|
+
head_sha, base_tree_sha = fetch_head_and_tree(client)
|
|
131
|
+
|
|
132
|
+
assign_paths!(pending, namespace_for(repo), issue_number)
|
|
133
|
+
tree_items = pending.map { |slot| blob_tree_item(client, slot) }
|
|
134
|
+
|
|
135
|
+
new_tree = client.rest(:create_tree, attachment_repo_full_name, tree_items, base_tree: base_tree_sha)
|
|
136
|
+
new_tree_sha = dig_sha(new_tree, :sha)
|
|
137
|
+
|
|
138
|
+
new_commit = client.rest(
|
|
139
|
+
:create_commit,
|
|
140
|
+
attachment_repo_full_name,
|
|
141
|
+
"task: Add attachments for #{repo.full_name}##{issue_number}",
|
|
142
|
+
new_tree_sha,
|
|
143
|
+
[head_sha],
|
|
144
|
+
)
|
|
145
|
+
dig_sha(new_commit, :sha)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Updates +config.main_branch+ to point at +commit_sha+. Called as the last step of +upload_all!+ so any
|
|
149
|
+
# earlier failure leaves the attachment repo unchanged.
|
|
150
|
+
#
|
|
151
|
+
# @param commit_sha [String]
|
|
152
|
+
#
|
|
153
|
+
# @return [void]
|
|
154
|
+
#
|
|
155
|
+
def publish_commit!(commit_sha)
|
|
156
|
+
PlanMyStuff.client.rest(:update_ref, attachment_repo_full_name, ref, commit_sha)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @param client [PlanMyStuff::Client]
|
|
160
|
+
#
|
|
161
|
+
# @return [Array(String, String)] [head_sha, tree_sha]
|
|
162
|
+
#
|
|
163
|
+
def fetch_head_and_tree(client)
|
|
164
|
+
ref_response = client.rest(:ref, attachment_repo_full_name, ref)
|
|
165
|
+
head_sha = dig_sha(ref_response, :object, :sha)
|
|
166
|
+
commit_response = client.rest(:commit, attachment_repo_full_name, head_sha)
|
|
167
|
+
tree_sha = dig_sha(commit_response, :commit, :tree, :sha)
|
|
168
|
+
[head_sha, tree_sha]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @param pending [Array<Hash>]
|
|
172
|
+
# @param namespace [String]
|
|
173
|
+
# @param issue_number [Integer]
|
|
174
|
+
#
|
|
175
|
+
# @return [void]
|
|
176
|
+
#
|
|
177
|
+
def assign_paths!(pending, namespace, issue_number)
|
|
178
|
+
pending.each do |slot|
|
|
179
|
+
uuid = SecureRandom.uuid
|
|
180
|
+
ext = File.extname(slot[:filename]).delete_prefix('.').downcase
|
|
181
|
+
basename = ext.empty? ? uuid : "#{uuid}.#{ext}"
|
|
182
|
+
slot[:basename] = basename
|
|
183
|
+
slot[:path] = "#{namespace}/issue-#{issue_number}/#{basename}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @param client [PlanMyStuff::Client]
|
|
188
|
+
# @param slot [Hash]
|
|
189
|
+
#
|
|
190
|
+
# @return [Hash] tree item ready for create_tree
|
|
191
|
+
#
|
|
192
|
+
def blob_tree_item(client, slot)
|
|
193
|
+
blob_sha = client.rest(
|
|
194
|
+
:create_blob, attachment_repo_full_name, Base64.strict_encode64(slot[:content]), 'base64',
|
|
195
|
+
)
|
|
196
|
+
{ path: slot[:path], mode: '100644', type: 'blob', sha: blob_sha }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @param commit_sha [String]
|
|
200
|
+
# @param slot [Hash]
|
|
201
|
+
#
|
|
202
|
+
# @return [PlanMyStuff::Attachment]
|
|
203
|
+
#
|
|
204
|
+
def build_attachment(commit_sha:, slot:)
|
|
205
|
+
PlanMyStuff::Attachment.new(
|
|
206
|
+
filename: slot[:filename],
|
|
207
|
+
owner: PlanMyStuff.configuration.organization,
|
|
208
|
+
repo: PlanMyStuff.configuration.attachment_repo,
|
|
209
|
+
sha: commit_sha,
|
|
210
|
+
path: slot[:path],
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# @return [String] +"<organization>/<attachment_repo>"+
|
|
215
|
+
def attachment_repo_full_name
|
|
216
|
+
"#{PlanMyStuff.configuration.organization}/#{PlanMyStuff.configuration.attachment_repo}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @return [String] Git ref for the configured main branch (e.g. +"heads/main"+)
|
|
220
|
+
def ref
|
|
221
|
+
"heads/#{PlanMyStuff.configuration.main_branch}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Walks +keys+ through +obj+, tolerating Sawyer resources (method accessors), Hash with Symbol keys, and
|
|
225
|
+
# Hash with String keys.
|
|
226
|
+
#
|
|
227
|
+
# @param obj [Object]
|
|
228
|
+
# @param keys [Array<Symbol>]
|
|
229
|
+
#
|
|
230
|
+
# @return [Object, nil]
|
|
231
|
+
#
|
|
232
|
+
def dig_sha(obj, *keys)
|
|
233
|
+
keys.reduce(obj) do |acc, key|
|
|
234
|
+
break if acc.nil?
|
|
235
|
+
|
|
236
|
+
if acc.respond_to?(key)
|
|
237
|
+
acc.public_send(key)
|
|
238
|
+
elsif acc.respond_to?(:[])
|
|
239
|
+
acc[key] || acc[key.to_s]
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|