plan_my_stuff 0.8.0 → 0.10.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.
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module IssueExtractions
5
+ module Waiting
6
+ # Marks the issue as waiting on an end-user reply. Sets +metadata.waiting_on_user_at+ to now, (re)computes
7
+ # +metadata.next_reminder_at+, and adds the configured +waiting_on_user_label+ to the issue. Called from
8
+ # +Comment.create!+ when a support user posts a comment with +waiting_on_reply: true+, and from the
9
+ # +Issues::WaitingsController+ toggle.
10
+ #
11
+ # @param user [Object, nil] actor for the label notification event
12
+ #
13
+ # @return [self]
14
+ #
15
+ def enter_waiting_on_user!(user: nil)
16
+ now = Time.now.utc
17
+ label = PlanMyStuff.configuration.waiting_on_user_label
18
+
19
+ PlanMyStuff::Label.ensure!(repo: repo, name: label)
20
+ PlanMyStuff::Label.add!(issue: self, labels: [label], user: user) if labels.exclude?(label)
21
+
22
+ self.class.update!(
23
+ number: number,
24
+ repo: repo,
25
+ metadata: {
26
+ waiting_on_user_at: PlanMyStuff.format_time(now),
27
+ next_reminder_at: format_next_reminder_at(from: now),
28
+ },
29
+ )
30
+ reload
31
+ end
32
+
33
+ # Clears the waiting-on-user state: removes the label, clears +metadata.waiting_on_user_at+, and clears
34
+ # +metadata.next_reminder_at+ unless a waiting-on-approval timer is still active. No-ops if the issue is not
35
+ # currently waiting on a user reply.
36
+ #
37
+ # @return [self]
38
+ #
39
+ def clear_waiting_on_user!
40
+ label = PlanMyStuff.configuration.waiting_on_user_label
41
+ return self if metadata.waiting_on_user_at.nil? && labels.exclude?(label)
42
+
43
+ PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
44
+
45
+ self.class.update!(
46
+ number: number,
47
+ repo: repo,
48
+ metadata: {
49
+ waiting_on_user_at: nil,
50
+ next_reminder_at:
51
+ metadata.waiting_on_approval_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
52
+ },
53
+ )
54
+ reload
55
+ end
56
+
57
+ # Reopens an issue that was auto-closed by the inactivity sweep, clears +metadata.closed_by_inactivity+, and
58
+ # emits +plan_my_stuff.issue.reopened_by_reply+ carrying the reopening comment. Does not emit the regular
59
+ # +issue.reopened+ event \- subscribers that specifically care about this flow subscribe to the dedicated event.
60
+ #
61
+ # @param comment [PlanMyStuff::Comment] the reopening comment
62
+ # @param user [Object, nil] actor for the notification event
63
+ #
64
+ # @return [self]
65
+ #
66
+ def reopen_by_reply!(comment:, user: nil)
67
+ inactive_label = PlanMyStuff.configuration.user_inactive_label
68
+ PlanMyStuff::Label.remove!(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
69
+
70
+ self.class.update!(
71
+ number: number,
72
+ repo: repo,
73
+ state: :open,
74
+ metadata: { closed_by_inactivity: false },
75
+ )
76
+ reload
77
+
78
+ PlanMyStuff::Notifications.instrument(
79
+ 'issue.reopened_by_reply',
80
+ self,
81
+ user: user,
82
+ comment: comment,
83
+ )
84
+ self
85
+ end
86
+
87
+ private
88
+
89
+ # Formats the next reminder time as an ISO 8601 UTC string, using per-issue +metadata.reminder_days+ when set
90
+ # or +config.reminder_days+ otherwise. Returns +nil+ when the effective schedule is empty.
91
+ #
92
+ # @param from [Time] baseline timestamp
93
+ #
94
+ # @return [String, nil]
95
+ #
96
+ def format_next_reminder_at(from:)
97
+ days = metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
98
+ return if days.empty?
99
+
100
+ PlanMyStuff.format_time(from + days.first.days)
101
+ end
102
+
103
+ # When an issue is transitioning from open to closed, strips both waiting labels from the outgoing labels
104
+ # array and clears the waiting-related timestamps on +metadata+ so a single save writes both state change and
105
+ # cleanup. No-op for any other transition.
106
+ #
107
+ # @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
108
+ #
109
+ # @return [void]
110
+ #
111
+ def clear_waiting_state_on_close(attrs)
112
+ return unless state_changed?
113
+ return unless state_was == 'open'
114
+ return unless state == 'closed'
115
+
116
+ return if metadata.waiting_on_user_at.blank? && metadata.waiting_on_approval_at.blank?
117
+
118
+ waiting_labels = [
119
+ PlanMyStuff.configuration.waiting_on_user_label,
120
+ PlanMyStuff.configuration.waiting_on_approval_label,
121
+ ]
122
+ attrs[:labels] = Array.wrap(attrs[:labels]) - waiting_labels
123
+
124
+ metadata.waiting_on_user_at = nil
125
+ metadata.waiting_on_approval_at = nil
126
+ metadata.next_reminder_at = nil
127
+ end
128
+
129
+ # When an inactivity-closed issue is being reopened, strips the +user_inactive_label+ from the outgoing labels
130
+ # and clears +metadata.closed_by_inactivity+ so the save writes both. No-op for any other transition or for
131
+ # reopens of non-inactive closes.
132
+ #
133
+ # @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
134
+ #
135
+ # @return [void]
136
+ #
137
+ def clear_inactivity_state_on_reopen(attrs)
138
+ return unless state_changed?
139
+ return unless state_was == 'closed'
140
+ return unless state == 'open'
141
+ return unless metadata.closed_by_inactivity
142
+
143
+ attrs[:labels] = Array.wrap(attrs[:labels]) - [PlanMyStuff.configuration.user_inactive_label]
144
+ metadata.closed_by_inactivity = false
145
+ end
146
+ end
147
+ end
148
+ end
@@ -48,7 +48,7 @@ module PlanMyStuff
48
48
  client = PlanMyStuff.client
49
49
  client.rest(:label, repo, name)
50
50
  rescue PlanMyStuff::APIError => e
51
- raise unless e.status == 404
51
+ raise(e) unless e.status == 404
52
52
 
53
53
  create_label!(client, repo, name, color, description)
54
54
  end
@@ -62,11 +62,11 @@ module PlanMyStuff
62
62
  #
63
63
  # @return [Boolean]
64
64
  #
65
- def exists?(repo:, name:)
65
+ def exists?(repo:, name:, do_raise: true)
66
66
  PlanMyStuff.client.rest(:label, repo, name)
67
67
  true
68
68
  rescue PlanMyStuff::APIError => e
69
- raise unless e.status == 404
69
+ raise(e) if do_raise && e.status != 404
70
70
 
71
71
  false
72
72
  end
@@ -115,7 +115,7 @@ module PlanMyStuff
115
115
 
116
116
  client.rest(:add_label, repo, name, color, **options)
117
117
  rescue PlanMyStuff::APIError => e
118
- raise unless e.status == 422
118
+ raise(e) unless e.status == 422
119
119
  end
120
120
 
121
121
  # Hydrates a Label from a GitHub API response.
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 8
6
+ MINOR = 10
7
7
  TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
@@ -46,7 +46,7 @@ namespace :plan_my_stuff do
46
46
  url = ENV.fetch('URL') { raise(ArgumentError, 'URL env var is required') }
47
47
  repo = ENV.fetch('REPO') do
48
48
  PlanMyStuff.client.resolve_repo!
49
- rescue
49
+ rescue PlanMyStuff::ConfigurationError, ArgumentError
50
50
  raise(ArgumentError, 'REPO env var is required or configured (e.g. BrandsInsurance/PlanMyStuff)')
51
51
  end
52
52
  default_events = %w[pull_request issues]
@@ -94,7 +94,7 @@ namespace :plan_my_stuff do
94
94
  end
95
95
 
96
96
  desc 'Continuously poll org + repo hooks and auto-replay new deliveries ' \
97
- '(ENDPOINT_URL=... [ORG_WEBHOOK_URL=...] [REPO_WEBHOOK_URL=... REPO=owner/name] [INTERVAL=15]). ' \
97
+ '(ENDPOINT_URL=... [ORG_WEBHOOK_URL=...] [REPO_WEBHOOK_URL=... REPO=owner/name] [INTERVAL=30]). ' \
98
98
  'At least one of ORG_WEBHOOK_URL or REPO_WEBHOOK_URL is required (raises ArgumentError if both absent).'
99
99
  task listen: :environment do
100
100
  require 'plan_my_stuff/webhook_replayer'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plan_my_stuff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brands Insurance
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-30 00:00:00.000000000 Z
11
+ date: 2026-05-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -52,6 +52,7 @@ extensions: []
52
52
  extra_rdoc_files: []
53
53
  files:
54
54
  - CHANGELOG.md
55
+ - CONFIGURATION.md
55
56
  - LICENSE
56
57
  - README.md
57
58
  - app/controllers/plan_my_stuff/application_controller.rb
@@ -112,6 +113,7 @@ files:
112
113
  - lib/plan_my_stuff/aws_sns_simulator.rb
113
114
  - lib/plan_my_stuff/base_metadata.rb
114
115
  - lib/plan_my_stuff/base_project.rb
116
+ - lib/plan_my_stuff/base_project_extractions/graphql_hydration.rb
115
117
  - lib/plan_my_stuff/base_project_item.rb
116
118
  - lib/plan_my_stuff/base_project_metadata.rb
117
119
  - lib/plan_my_stuff/cache.rb
@@ -124,6 +126,10 @@ files:
124
126
  - lib/plan_my_stuff/errors.rb
125
127
  - lib/plan_my_stuff/graphql/queries.rb
126
128
  - lib/plan_my_stuff/issue.rb
129
+ - lib/plan_my_stuff/issue_extractions/approvals.rb
130
+ - lib/plan_my_stuff/issue_extractions/links.rb
131
+ - lib/plan_my_stuff/issue_extractions/viewers.rb
132
+ - lib/plan_my_stuff/issue_extractions/waiting.rb
127
133
  - lib/plan_my_stuff/issue_metadata.rb
128
134
  - lib/plan_my_stuff/label.rb
129
135
  - lib/plan_my_stuff/link.rb