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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +595 -0
  3. data/CONFIGURATION.md +487 -0
  4. data/README.md +612 -88
  5. data/app/controllers/plan_my_stuff/application_controller.rb +27 -5
  6. data/app/controllers/plan_my_stuff/comments_controller.rb +50 -19
  7. data/app/controllers/plan_my_stuff/issues/approvals_controller.rb +127 -0
  8. data/app/controllers/plan_my_stuff/issues/closures_controller.rb +53 -0
  9. data/app/controllers/plan_my_stuff/issues/links_controller.rb +129 -0
  10. data/app/controllers/plan_my_stuff/issues/takes_controller.rb +161 -0
  11. data/app/controllers/plan_my_stuff/issues/testings_controller.rb +82 -0
  12. data/app/controllers/plan_my_stuff/issues/viewers_controller.rb +62 -0
  13. data/app/controllers/plan_my_stuff/issues/waitings_controller.rb +55 -0
  14. data/app/controllers/plan_my_stuff/issues_controller.rb +53 -70
  15. data/app/controllers/plan_my_stuff/labels_controller.rb +32 -10
  16. data/app/controllers/plan_my_stuff/project_items/assignments_controller.rb +88 -0
  17. data/app/controllers/plan_my_stuff/project_items/statuses_controller.rb +44 -0
  18. data/app/controllers/plan_my_stuff/project_items_controller.rb +32 -69
  19. data/app/controllers/plan_my_stuff/projects_controller.rb +81 -3
  20. data/app/controllers/plan_my_stuff/testing_project_items/results_controller.rb +67 -0
  21. data/app/controllers/plan_my_stuff/testing_project_items_controller.rb +49 -0
  22. data/app/controllers/plan_my_stuff/testing_projects_controller.rb +121 -0
  23. data/app/controllers/plan_my_stuff/webhooks/aws_controller.rb +202 -0
  24. data/app/controllers/plan_my_stuff/webhooks/github_controller.rb +371 -0
  25. data/app/jobs/plan_my_stuff/application_job.rb +8 -0
  26. data/app/jobs/plan_my_stuff/reminders_sweep_job.rb +75 -0
  27. data/app/views/plan_my_stuff/comments/edit.html.erb +1 -3
  28. data/app/views/plan_my_stuff/comments/partials/_form.html.erb +8 -0
  29. data/app/views/plan_my_stuff/issues/edit.html.erb +2 -4
  30. data/app/views/plan_my_stuff/issues/index.html.erb +5 -5
  31. data/app/views/plan_my_stuff/issues/new.html.erb +2 -4
  32. data/app/views/plan_my_stuff/issues/partials/_approvals.html.erb +108 -0
  33. data/app/views/plan_my_stuff/issues/partials/_form.html.erb +11 -6
  34. data/app/views/plan_my_stuff/issues/partials/_labels.html.erb +4 -3
  35. data/app/views/plan_my_stuff/issues/partials/_links.html.erb +113 -0
  36. data/app/views/plan_my_stuff/issues/partials/_viewers.html.erb +4 -3
  37. data/app/views/plan_my_stuff/issues/show.html.erb +67 -6
  38. data/app/views/plan_my_stuff/partials/_flash.html.erb +3 -0
  39. data/app/views/plan_my_stuff/projects/edit.html.erb +5 -0
  40. data/app/views/plan_my_stuff/projects/index.html.erb +18 -2
  41. data/app/views/plan_my_stuff/projects/new.html.erb +5 -0
  42. data/app/views/plan_my_stuff/projects/partials/_form.html.erb +30 -0
  43. data/app/views/plan_my_stuff/projects/show.html.erb +30 -11
  44. data/app/views/plan_my_stuff/testing_project_items/new.html.erb +10 -0
  45. data/app/views/plan_my_stuff/testing_project_items/results/new.html.erb +20 -0
  46. data/app/views/plan_my_stuff/testing_projects/edit.html.erb +5 -0
  47. data/app/views/plan_my_stuff/testing_projects/new.html.erb +5 -0
  48. data/app/views/plan_my_stuff/testing_projects/partials/_form.html.erb +40 -0
  49. data/app/views/plan_my_stuff/testing_projects/partials/_item.html.erb +52 -0
  50. data/app/views/plan_my_stuff/testing_projects/partials/items/_form.html.erb +36 -0
  51. data/app/views/plan_my_stuff/testing_projects/show.html.erb +65 -0
  52. data/config/routes.rb +43 -15
  53. data/lib/generators/plan_my_stuff/install/templates/initializer.rb +302 -20
  54. data/lib/plan_my_stuff/application_record.rb +158 -1
  55. data/lib/plan_my_stuff/approval.rb +88 -0
  56. data/lib/plan_my_stuff/archive/sweep.rb +85 -0
  57. data/lib/plan_my_stuff/archive.rb +12 -0
  58. data/lib/plan_my_stuff/attachment.rb +83 -0
  59. data/lib/plan_my_stuff/attachment_uploader.rb +245 -0
  60. data/lib/plan_my_stuff/aws_sns_simulator.rb +116 -0
  61. data/lib/plan_my_stuff/base_metadata.rb +25 -28
  62. data/lib/plan_my_stuff/base_project.rb +502 -0
  63. data/lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb +186 -0
  64. data/lib/plan_my_stuff/base_project_item.rb +588 -0
  65. data/lib/plan_my_stuff/base_project_metadata.rb +16 -0
  66. data/lib/plan_my_stuff/cache.rb +197 -0
  67. data/lib/plan_my_stuff/client.rb +139 -64
  68. data/lib/plan_my_stuff/comment.rb +225 -100
  69. data/lib/plan_my_stuff/comment_metadata.rb +68 -5
  70. data/lib/plan_my_stuff/configuration.rb +459 -28
  71. data/lib/plan_my_stuff/custom_fields.rb +96 -12
  72. data/lib/plan_my_stuff/engine.rb +14 -2
  73. data/lib/plan_my_stuff/errors.rb +65 -5
  74. data/lib/plan_my_stuff/graphql/queries.rb +454 -0
  75. data/lib/plan_my_stuff/issue.rb +1097 -166
  76. data/lib/plan_my_stuff/issue_extractions/approvals.rb +370 -0
  77. data/lib/plan_my_stuff/issue_extractions/links.rb +525 -0
  78. data/lib/plan_my_stuff/issue_extractions/viewers.rb +75 -0
  79. data/lib/plan_my_stuff/issue_extractions/waiting.rb +171 -0
  80. data/lib/plan_my_stuff/issue_field.rb +126 -0
  81. data/lib/plan_my_stuff/issue_field_translation.rb +67 -0
  82. data/lib/plan_my_stuff/issue_field_value_set.rb +68 -0
  83. data/lib/plan_my_stuff/issue_metadata.rb +132 -21
  84. data/lib/plan_my_stuff/label.rb +100 -13
  85. data/lib/plan_my_stuff/link.rb +144 -0
  86. data/lib/plan_my_stuff/markdown.rb +13 -7
  87. data/lib/plan_my_stuff/metadata_parser.rb +51 -12
  88. data/lib/plan_my_stuff/notifications.rb +148 -0
  89. data/lib/plan_my_stuff/pipeline/completed_sweep.rb +46 -0
  90. data/lib/plan_my_stuff/pipeline/issue_linker.rb +62 -0
  91. data/lib/plan_my_stuff/pipeline/status.rb +40 -0
  92. data/lib/plan_my_stuff/pipeline/testing.rb +23 -0
  93. data/lib/plan_my_stuff/pipeline.rb +310 -0
  94. data/lib/plan_my_stuff/project.rb +63 -465
  95. data/lib/plan_my_stuff/project_item.rb +3 -409
  96. data/lib/plan_my_stuff/project_item_metadata.rb +55 -0
  97. data/lib/plan_my_stuff/project_metadata.rb +47 -0
  98. data/lib/plan_my_stuff/reminders/closer.rb +70 -0
  99. data/lib/plan_my_stuff/reminders/fire.rb +129 -0
  100. data/lib/plan_my_stuff/reminders/sweep.rb +54 -0
  101. data/lib/plan_my_stuff/reminders.rb +12 -0
  102. data/lib/plan_my_stuff/repo.rb +145 -0
  103. data/lib/plan_my_stuff/test_helpers.rb +265 -25
  104. data/lib/plan_my_stuff/testing_project.rb +292 -0
  105. data/lib/plan_my_stuff/testing_project_item.rb +218 -0
  106. data/lib/plan_my_stuff/testing_project_metadata.rb +94 -0
  107. data/lib/plan_my_stuff/user_resolver.rb +24 -3
  108. data/lib/plan_my_stuff/verifier.rb +10 -0
  109. data/lib/plan_my_stuff/version.rb +2 -2
  110. data/lib/plan_my_stuff/webhook_replayer.rb +292 -0
  111. data/lib/plan_my_stuff.rb +55 -20
  112. data/lib/tasks/plan_my_stuff.rake +331 -0
  113. metadata +99 -4
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Reminders
5
+ # Emits +issue_reminder_due.plan_my_stuff+ for a single waiting issue
6
+ # and advances its +next_reminder_at+ to the next milestone in the
7
+ # effective +reminder_days+ schedule (or +nil+ when the last milestone
8
+ # has passed).
9
+ class Fire
10
+ # Returns +true+ when the issue has a +next_reminder_at+ in the
11
+ # past relative to +now+.
12
+ #
13
+ # @param issue [PlanMyStuff::Issue]
14
+ # @param now [Time]
15
+ #
16
+ # @return [Boolean]
17
+ #
18
+ def self.ready?(issue, now: Time.now.utc)
19
+ due = issue.metadata.next_reminder_at
20
+ due.present? && due <= now
21
+ end
22
+
23
+ # @param issue [PlanMyStuff::Issue] candidate issue from the sweep
24
+ # @param now [Time] clock reference (defaults to +Time.now.utc+)
25
+ #
26
+ def initialize(issue, now: Time.now.utc)
27
+ @issue = issue
28
+ @now = now.utc
29
+ end
30
+
31
+ # Emits the reminder event and advances the schedule.
32
+ #
33
+ # @return [void]
34
+ #
35
+ def call
36
+ payload = build_payload
37
+ PlanMyStuff::Notifications.instrument('issue_reminder_due', @issue, **payload)
38
+
39
+ @issue.update!(
40
+ metadata: { next_reminder_at: next_reminder_at_value },
41
+ skip_notification: true,
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ # @return [Hash]
48
+ def build_payload
49
+ payload = {
50
+ waiting_kind: waiting_kind,
51
+ days_waiting: days_waiting,
52
+ reminder_day: reminder_day,
53
+ last_activity_at: last_activity_at,
54
+ }
55
+ payload[:pending_approvers] = pending_approvers if waiting_kind == :approval
56
+ payload
57
+ end
58
+
59
+ # @return [Symbol] +:user+ or +:approval+
60
+ def waiting_kind
61
+ @issue.metadata.waiting_on_user_at.present? ? :user : :approval
62
+ end
63
+
64
+ # @return [Time] the timestamp waiting started for the active kind
65
+ def starting_clock
66
+ @issue.metadata.waiting_on_user_at || @issue.metadata.waiting_on_approval_at
67
+ end
68
+
69
+ # @return [Integer]
70
+ def days_waiting
71
+ ((@now - starting_clock) / 1.day).to_i
72
+ end
73
+
74
+ # Most recent milestone passed, used as the +reminder_day+ label in
75
+ # the event payload.
76
+ #
77
+ # @return [Integer, nil]
78
+ #
79
+ def reminder_day
80
+ effective_reminder_days.select { |d| d <= days_waiting }.max
81
+ end
82
+
83
+ # Informational: last comment timestamp (falling back to the
84
+ # issue's GitHub +updated_at+ when there are no comments). Uses
85
+ # +updated_at+ on Comment since that's the only timestamp PMS
86
+ # exposes on comment records.
87
+ #
88
+ # @return [Time, nil]
89
+ #
90
+ def last_activity_at
91
+ last_comment = @issue.comments.max_by { |c| c.updated_at || Time.at(0).utc }
92
+ last_comment&.updated_at || @issue.updated_at
93
+ end
94
+
95
+ # Resolves pending-approval user IDs via +UserResolver+. IDs that
96
+ # fail to resolve (e.g. deleted users) are dropped.
97
+ #
98
+ # @return [Array<Object>]
99
+ #
100
+ def pending_approvers
101
+ @issue.pending_approvals.filter_map do |approval|
102
+ PlanMyStuff::UserResolver.resolve(approval.user_id)
103
+ rescue ActiveRecord::RecordNotFound
104
+ next
105
+ end
106
+ end
107
+
108
+ # @return [Array<Integer>]
109
+ def effective_reminder_days
110
+ (@issue.metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days).sort
111
+ end
112
+
113
+ # Time of the next milestone past +now+, or +nil+ when the last
114
+ # milestone has passed. Returned as a native +Time+ (not ISO
115
+ # string) so +IssueMetadata#to_h+'s +PlanMyStuff.format_time+ serializes it
116
+ # cleanly.
117
+ #
118
+ # @return [Time, nil]
119
+ #
120
+ def next_reminder_at_value
121
+ start = starting_clock
122
+ remaining = effective_reminder_days.select { |d| start + d.days > @now }
123
+ return if remaining.empty?
124
+
125
+ (start + remaining.first.days).utc
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Reminders
5
+ # Walks a single repo's waiting issues, dispatching each to +Closer+
6
+ # when past the inactivity ceiling or +Fire+ when a reminder is due.
7
+ # Called from +RemindersSweepJob+; no ActiveJob dependency here so
8
+ # the logic stays unit-testable and callable from a plain rake task.
9
+ class Sweep
10
+ # @param repo [Symbol, String] repo key or full name
11
+ # @param now [Time] clock reference
12
+ #
13
+ def initialize(repo:, now: Time.now.utc)
14
+ @repo = repo
15
+ @now = now.utc
16
+ end
17
+
18
+ # Runs the sweep. No-op when +config.reminders_enabled+ is false.
19
+ #
20
+ # @return [void]
21
+ #
22
+ def call
23
+ return unless PlanMyStuff.configuration.reminders_enabled
24
+
25
+ candidates.each do |issue|
26
+ if PlanMyStuff::Reminders::Closer.should_close?(issue, now: @now)
27
+ PlanMyStuff::Reminders::Closer.new(issue, now: @now).call
28
+ elsif PlanMyStuff::Reminders::Fire.ready?(issue, now: @now)
29
+ PlanMyStuff::Reminders::Fire.new(issue, now: @now).call
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Issues carrying either waiting label, deduplicated by number.
37
+ # GitHub's labels filter is AND across multiple labels, so we
38
+ # query each label separately and merge.
39
+ #
40
+ # @return [Array<PlanMyStuff::Issue>]
41
+ #
42
+ def candidates
43
+ user_label = PlanMyStuff.configuration.waiting_on_user_label
44
+ approval_label = PlanMyStuff.configuration.waiting_on_approval_label
45
+
46
+ by_label = [user_label, approval_label].flat_map do |label|
47
+ PlanMyStuff::Issue.list(repo: @repo, labels: [label], state: :open)
48
+ end
49
+
50
+ by_label.uniq(&:number)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Follow-up reminder engine. The +Sweep+ class walks the waiting issues
5
+ # in a repo and dispatches to +Fire+ for reminders that are due or
6
+ # +Closer+ for issues that have exceeded the inactivity threshold.
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 Reminders
11
+ end
12
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ class Repo
5
+ # @return [Symbol, nil] configured key (e.g. :my_repo)
6
+ attr_reader :key
7
+
8
+ # @return [String] repo name (e.g. "MyRepository")
9
+ attr_reader :name
10
+
11
+ # @return [String] organization name (e.g. "YourOrgName")
12
+ attr_reader :organization
13
+
14
+ delegate :split, to: :to_s
15
+
16
+ class << self
17
+ # Builds a Repo instance from a Symbol key, full name String, or nil (default).
18
+ #
19
+ # @raise [PlanMyStuff::ConfigurationError] if repo is not provided and cannot be resolved from config
20
+ # @raise [ArgumentError] if repo cannot be resolved
21
+ # @raise [ArgumentError] if repo is invalid format
22
+ #
23
+ # @param repo [Symbol, String, PlanMyStuff::Repo, nil]
24
+ #
25
+ # @return [PlanMyStuff::Repo]
26
+ #
27
+ def resolve!(repo = nil)
28
+ return repo if repo.is_a?(PlanMyStuff::Repo)
29
+
30
+ repo ||= PlanMyStuff.configuration.default_repo
31
+
32
+ if repo.nil?
33
+ raise(
34
+ PlanMyStuff::ConfigurationError,
35
+ 'No repo provided and config.default_repo is not set. ' \
36
+ 'Either pass repo: explicitly or set config.default_repo in your initializer.',
37
+ )
38
+ end
39
+
40
+ case repo
41
+ when Symbol
42
+ full_name = PlanMyStuff.configuration.repos[repo]
43
+ raise(ArgumentError, "Unknown repo key: #{repo.inspect}") if full_name.nil?
44
+
45
+ from_full_name!(full_name, key: repo)
46
+ when String
47
+ if PlanMyStuff.configuration.repos.has_key?(repo.to_sym)
48
+ resolve!(repo.to_sym)
49
+ else
50
+ key = PlanMyStuff.configuration.repos.key(repo)
51
+ from_full_name!(repo, key: key)
52
+ end
53
+ else
54
+ raise(ArgumentError, "Cannot resolve repo: #{repo.inspect}")
55
+ end
56
+ end
57
+
58
+ # Reverse lookup for the +Issue#to_param+ prefix: finds the configured repo whose nickname (per
59
+ # +config.repo_nickname_for+) matches +nickname+ and returns its +Repo+ instance.
60
+ #
61
+ # @raise [ArgumentError] if no configured repo has the given nickname
62
+ #
63
+ # @param nickname [String]
64
+ #
65
+ # @return [PlanMyStuff::Repo]
66
+ #
67
+ def from_nickname!(nickname)
68
+ config = PlanMyStuff.configuration
69
+ match = config.repos.keys.find { |key| config.repo_nickname_for(key) == nickname }
70
+ raise(ArgumentError, "Unknown repo nickname: #{nickname.inspect}") if match.nil?
71
+
72
+ resolve!(match)
73
+ end
74
+
75
+ private
76
+
77
+ # @raise [ArgumentError] if full_name is not in "Org/Repo" format
78
+ #
79
+ # @param full_name [String] e.g. "YourOrgName/MyRepository"
80
+ # @param key [Symbol, nil]
81
+ #
82
+ # @return [PlanMyStuff::Repo]
83
+ #
84
+ def from_full_name!(full_name, key: nil)
85
+ org, name = full_name.split('/', 2)
86
+
87
+ raise(ArgumentError, "Invalid repo full_name: #{full_name.inspect}") if name.nil?
88
+
89
+ new(name: name, organization: org, key: key)
90
+ end
91
+ end
92
+
93
+ # @param name [String]
94
+ # @param organization [String]
95
+ # @param key [Symbol, nil]
96
+ #
97
+ def initialize(name:, organization:, key: nil)
98
+ @key = key
99
+ @name = name
100
+ @organization = organization
101
+ end
102
+
103
+ # @return [String] full repo path (e.g. "YourOrgName/MyRepository")
104
+ def full_name
105
+ "#{organization}/#{name}"
106
+ end
107
+
108
+ # Human-readable repo label used as the +Issue#to_param+ prefix. Resolves through +config.repo_nickname_for+
109
+ # when this repo carries a configured +key+; falls back to the bare repo +name+ for unconfigured repos.
110
+ #
111
+ # @return [String]
112
+ #
113
+ def nickname
114
+ return PlanMyStuff.configuration.repo_nickname_for(key) if key
115
+
116
+ name
117
+ end
118
+
119
+ # @see #full_name
120
+ alias to_s full_name
121
+
122
+ # Enables implicit string coercion so Repo instances behave as strings when passed to Octokit or compared with
123
+ # String#==.
124
+ #
125
+ # @see #full_name
126
+ alias to_str full_name
127
+
128
+ # Compares by full_name. Accepts another Repo or a String.
129
+ #
130
+ # @param other [PlanMyStuff::Repo, String, Object]
131
+ #
132
+ # @return [Boolean]
133
+ #
134
+ def ==(other)
135
+ case other
136
+ when PlanMyStuff::Repo
137
+ full_name == other.full_name
138
+ when String
139
+ full_name == other
140
+ else
141
+ super
142
+ end
143
+ end
144
+ end
145
+ end