plan_my_stuff 0.3.0 → 0.4.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 +10 -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 +138 -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 +184 -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 +163 -0
- metadata +50 -2
|
@@ -0,0 +1,80 @@
|
|
|
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!+, 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].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"+ or +"approved"+
|
|
24
|
+
attribute :status, :string, default: 'pending'
|
|
25
|
+
# @return [DateTime, nil] timestamp when status flipped to +"approved"+
|
|
26
|
+
attribute :approved_at, :datetime
|
|
27
|
+
|
|
28
|
+
validates :user_id, presence: true, numericality: { greater_than: 0, only_integer: true }
|
|
29
|
+
validates :status, inclusion: { in: STATUSES }
|
|
30
|
+
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def pending?
|
|
33
|
+
status == 'pending'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def approved?
|
|
38
|
+
status == 'approved'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Lazy-resolves the app-side user for this approval.
|
|
42
|
+
# Not memoized -- +PlanMyStuff::UserResolver+ owns caching.
|
|
43
|
+
#
|
|
44
|
+
# @return [Object, nil]
|
|
45
|
+
#
|
|
46
|
+
def user
|
|
47
|
+
PlanMyStuff::UserResolver.resolve(user_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Hash]
|
|
51
|
+
def to_h
|
|
52
|
+
{
|
|
53
|
+
user_id: user_id,
|
|
54
|
+
status: status,
|
|
55
|
+
approved_at: approved_at&.iso8601,
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Two approvals are equal when they track the same user AND carry the
|
|
60
|
+
# same status. A pending and an approved record for the same user are
|
|
61
|
+
# NOT equal -- matters for set arithmetic during state transitions.
|
|
62
|
+
#
|
|
63
|
+
# @param other [Object]
|
|
64
|
+
#
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
#
|
|
67
|
+
def ==(other)
|
|
68
|
+
return false unless other.is_a?(PlanMyStuff::Approval)
|
|
69
|
+
|
|
70
|
+
user_id == other.user_id && status == other.status
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
alias eql? ==
|
|
74
|
+
|
|
75
|
+
# @return [Integer]
|
|
76
|
+
def hash
|
|
77
|
+
[user_id, status].hash
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
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,14 @@
|
|
|
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
|
|
13
|
+
|
|
14
|
+
require_relative 'archive/sweep'
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
module PlanMyStuff
|
|
10
|
+
# Dev helper: build a fake SNS notification wrapping an ECS Deployment
|
|
11
|
+
# State Change event and POST it to a local +/webhooks/aws+ endpoint.
|
|
12
|
+
# Used by +plan_my_stuff:webhooks:simulate_aws+ so deployment flows
|
|
13
|
+
# can be exercised without waiting for a real AWS deployment.
|
|
14
|
+
#
|
|
15
|
+
# The consuming app must be configured with a no-op SNS verifier
|
|
16
|
+
# (see +PlanMyStuff::NullSnsVerifier+) or signature verification will
|
|
17
|
+
# reject the simulated envelope. Never enable that verifier in
|
|
18
|
+
# production.
|
|
19
|
+
#
|
|
20
|
+
module AwsSnsSimulator
|
|
21
|
+
DEFAULT_EVENT = 'SERVICE_DEPLOYMENT_COMPLETED'
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# POSTs a simulated SNS envelope to the endpoint. Pulls
|
|
26
|
+
# +sns_topic_arn+, +aws_service_identifier+, and
|
|
27
|
+
# +production_commit_sha+ from +PlanMyStuff.configuration+ (this
|
|
28
|
+
# task is dev-only, so there's no reason to parameterize them).
|
|
29
|
+
#
|
|
30
|
+
# @param endpoint_url [String] full URL including path
|
|
31
|
+
# @param event_name [String] ECS eventName (default completed)
|
|
32
|
+
#
|
|
33
|
+
# @return [Net::HTTPResponse]
|
|
34
|
+
#
|
|
35
|
+
def post(endpoint_url:, event_name: DEFAULT_EVENT)
|
|
36
|
+
config = PlanMyStuff.configuration
|
|
37
|
+
raise('PlanMyStuff.configuration.sns_topic_arn is blank') if config.sns_topic_arn.blank?
|
|
38
|
+
raise('PlanMyStuff.configuration.aws_service_identifier is blank') if config.aws_service_identifier.blank?
|
|
39
|
+
|
|
40
|
+
topic_arn = config.sns_topic_arn
|
|
41
|
+
service_arn = "arn:aws:ecs:us-east-1:000000000000:service/simulated-cluster/#{config.aws_service_identifier}"
|
|
42
|
+
commit_sha = config.production_commit_sha
|
|
43
|
+
|
|
44
|
+
message = build_ecs_message(event_name: event_name, service_arn: service_arn, commit_sha: commit_sha)
|
|
45
|
+
envelope = build_sns_envelope(message: message, topic_arn: topic_arn)
|
|
46
|
+
raw_body = JSON.generate(envelope)
|
|
47
|
+
|
|
48
|
+
uri = URI(endpoint_url)
|
|
49
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
50
|
+
http.use_ssl = uri.scheme == 'https'
|
|
51
|
+
|
|
52
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
53
|
+
request['Content-Type'] = 'application/json'
|
|
54
|
+
request['x-amz-sns-message-type'] = 'Notification'
|
|
55
|
+
request.body = raw_body
|
|
56
|
+
|
|
57
|
+
$stdout.puts("POST #{endpoint_url}")
|
|
58
|
+
$stdout.puts(" event: #{event_name}")
|
|
59
|
+
$stdout.puts(" topic: #{topic_arn}")
|
|
60
|
+
$stdout.puts(" resources: #{service_arn}")
|
|
61
|
+
$stdout.puts(" commit: #{commit_sha || '(none)'}")
|
|
62
|
+
$stdout.puts('---')
|
|
63
|
+
|
|
64
|
+
response = http.request(request)
|
|
65
|
+
$stdout.puts("HTTP #{response.code} #{response.message}")
|
|
66
|
+
$stdout.puts(response.body) if response.body.present?
|
|
67
|
+
response
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Hash]
|
|
71
|
+
def build_ecs_message(event_name:, service_arn:, commit_sha:)
|
|
72
|
+
detail = {
|
|
73
|
+
'eventType' => 'INFO',
|
|
74
|
+
'eventName' => event_name,
|
|
75
|
+
'deploymentId' => "ecs-svc/#{SecureRandom.hex(8)}",
|
|
76
|
+
'updatedAt' => Time.now.utc.iso8601,
|
|
77
|
+
}
|
|
78
|
+
detail['commitSha'] = commit_sha if commit_sha
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
'version' => '0',
|
|
82
|
+
'id' => SecureRandom.uuid,
|
|
83
|
+
'detail-type' => 'ECS Deployment State Change',
|
|
84
|
+
'source' => 'aws.ecs',
|
|
85
|
+
'account' => '000000000000',
|
|
86
|
+
'time' => Time.now.utc.iso8601,
|
|
87
|
+
'region' => 'us-east-1',
|
|
88
|
+
'resources' => [service_arn],
|
|
89
|
+
'detail' => detail,
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Hash]
|
|
94
|
+
def build_sns_envelope(message:, topic_arn:)
|
|
95
|
+
now = Time.now.utc.iso8601
|
|
96
|
+
{
|
|
97
|
+
'Type' => 'Notification',
|
|
98
|
+
'MessageId' => SecureRandom.uuid,
|
|
99
|
+
'TopicArn' => topic_arn,
|
|
100
|
+
'Subject' => 'ECS Deployment State Change',
|
|
101
|
+
'Message' => JSON.generate(message),
|
|
102
|
+
'Timestamp' => now,
|
|
103
|
+
'SignatureVersion' => '1',
|
|
104
|
+
'Signature' => 'simulated',
|
|
105
|
+
'SigningCertURL' => 'https://example.com/cert.pem',
|
|
106
|
+
'UnsubscribeURL' => 'https://example.com/unsubscribe',
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|