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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module IssueExtractions
5
+ module Waiting
6
+ # @return [Boolean]
7
+ def awaiting_reply?
8
+ issue_fields['Issue Status'] == 'Waiting on Reply'
9
+ end
10
+
11
+ # Marks the issue as waiting on an end-user reply. Sets +metadata.waiting_on_user_at+ to now, (re)computes
12
+ # +metadata.next_reminder_at+, and adds the configured +waiting_on_user_label+ to the issue. Called from
13
+ # +Comment.create!+ when a support user posts a comment with +waiting_on_reply: true+, and from the
14
+ # +Issues::WaitingsController+ toggle.
15
+ #
16
+ # @param user [Object, nil] actor for the label notification event
17
+ #
18
+ # @return [self]
19
+ #
20
+ def enter_waiting_on_user!(user: nil)
21
+ now = Time.now.utc
22
+ label = PlanMyStuff.configuration.waiting_on_user_label
23
+
24
+ PlanMyStuff::Label.ensure!(repo: repo, name: label)
25
+ PlanMyStuff::Label.add!(issue: self, labels: [label], user: user) if labels.exclude?(label)
26
+
27
+ to_update = {}
28
+ if PlanMyStuff.configuration.issue_fields_enabled
29
+ to_update[:issue_fields] = { 'Issue Status' => 'Waiting on Reply' }
30
+ end
31
+
32
+ self.class.update!(
33
+ number: number,
34
+ repo: repo,
35
+ metadata: {
36
+ waiting_on_user_at: PlanMyStuff.format_time(now),
37
+ next_reminder_at: format_next_reminder_at(from: now),
38
+ },
39
+ **to_update,
40
+ )
41
+ reload
42
+ end
43
+
44
+ # Clears the waiting-on-user state: removes the label, clears +metadata.waiting_on_user_at+, and clears
45
+ # +metadata.next_reminder_at+ unless a waiting-on-approval timer is still active. No-ops if the issue is not
46
+ # currently waiting on a user reply.
47
+ #
48
+ # @return [self]
49
+ #
50
+ def clear_waiting_on_user!
51
+ label = PlanMyStuff.configuration.waiting_on_user_label
52
+ return self if metadata.waiting_on_user_at.nil? && labels.exclude?(label)
53
+
54
+ PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
55
+
56
+ to_update = {}
57
+ if PlanMyStuff.configuration.issue_fields_enabled
58
+ to_update[:issue_fields] = { 'Issue Status' => 'Open' }
59
+ end
60
+
61
+ self.class.update!(
62
+ number: number,
63
+ repo: repo,
64
+ metadata: {
65
+ waiting_on_user_at: nil,
66
+ next_reminder_at:
67
+ metadata.waiting_on_approval_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
68
+ },
69
+ **to_update,
70
+ )
71
+ reload
72
+ end
73
+
74
+ # Reopens an issue that was auto-closed by the inactivity sweep, clears +metadata.closed_by_inactivity+, and
75
+ # emits +issue_reopened_by_reply.plan_my_stuff+ carrying the reopening comment. Does not emit the regular
76
+ # +issue.reopened+ event \- subscribers that specifically care about this flow subscribe to the dedicated event.
77
+ #
78
+ # @param comment [PlanMyStuff::Comment] the reopening comment
79
+ # @param user [Object, nil] actor for the notification event
80
+ #
81
+ # @return [self]
82
+ #
83
+ def reopen_by_reply!(comment:, user: nil)
84
+ inactive_label = PlanMyStuff.configuration.user_inactive_label
85
+ PlanMyStuff::Label.remove!(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
86
+
87
+ to_update = {}
88
+ if PlanMyStuff.configuration.issue_fields_enabled
89
+ to_update[:issue_fields] = { 'Issue Status' => 'Reopened' }
90
+ end
91
+
92
+ self.class.update!(
93
+ number: number,
94
+ repo: repo,
95
+ state: :open,
96
+ metadata: { closed_by_inactivity: false },
97
+ **to_update,
98
+ )
99
+ reload
100
+
101
+ PlanMyStuff::Notifications.instrument(
102
+ 'issue_reopened_by_reply',
103
+ self,
104
+ user: user,
105
+ comment: comment,
106
+ )
107
+ self
108
+ end
109
+
110
+ private
111
+
112
+ # Formats the next reminder time as an ISO 8601 UTC string, using per-issue +metadata.reminder_days+ when set
113
+ # or +config.reminder_days+ otherwise. Returns +nil+ when the effective schedule is empty.
114
+ #
115
+ # @param from [Time] baseline timestamp
116
+ #
117
+ # @return [String, nil]
118
+ #
119
+ def format_next_reminder_at(from:)
120
+ days = metadata.reminder_days.presence || PlanMyStuff.configuration.reminder_days
121
+ return if days.empty?
122
+
123
+ PlanMyStuff.format_time(from + days.first.days)
124
+ end
125
+
126
+ # When an issue is transitioning from open to closed, strips both waiting labels from the outgoing labels
127
+ # array and clears the waiting-related timestamps on +metadata+ so a single save writes both state change and
128
+ # cleanup. No-op for any other transition.
129
+ #
130
+ # @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
131
+ #
132
+ # @return [void]
133
+ #
134
+ def clear_waiting_state_on_close(attrs)
135
+ return unless state_changed?
136
+ return unless state_was == 'open'
137
+ return unless state == 'closed'
138
+
139
+ return if metadata.waiting_on_user_at.blank? && metadata.waiting_on_approval_at.blank?
140
+
141
+ waiting_labels = [
142
+ PlanMyStuff.configuration.waiting_on_user_label,
143
+ PlanMyStuff.configuration.waiting_on_approval_label,
144
+ ]
145
+ attrs[:labels] = Array.wrap(attrs[:labels]) - waiting_labels
146
+
147
+ metadata.waiting_on_user_at = nil
148
+ metadata.waiting_on_approval_at = nil
149
+ metadata.next_reminder_at = nil
150
+ end
151
+
152
+ # When an inactivity-closed issue is being reopened, strips the +user_inactive_label+ from the outgoing labels
153
+ # and clears +metadata.closed_by_inactivity+ so the save writes both. No-op for any other transition or for
154
+ # reopens of non-inactive closes.
155
+ #
156
+ # @param attrs [Hash] the kwargs hash being assembled for +Issue.update!+; mutated in place
157
+ #
158
+ # @return [void]
159
+ #
160
+ def clear_inactivity_state_on_reopen(attrs)
161
+ return unless state_changed?
162
+ return unless state_was == 'closed'
163
+ return unless state == 'open'
164
+ return unless metadata.closed_by_inactivity
165
+
166
+ attrs[:labels] = Array.wrap(attrs[:labels]) - [PlanMyStuff.configuration.user_inactive_label]
167
+ metadata.closed_by_inactivity = false
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Value object wrapping an organization-level GitHub Issue Field definition (public preview).
5
+ # Issue Fields are structured per-issue metadata (text, number, date, or single-select)
6
+ # configured once at the org level and applied across all of the org's repositories.
7
+ #
8
+ # Read-only on the gem side: callers manage field *definitions* through the GitHub UI, while
9
+ # the gem only handles field *values* on individual issues (see +Issue#issue_fields+).
10
+ class IssueField
11
+ # GraphQL +__typename+ -> normalized type symbol used internally.
12
+ TYPES = {
13
+ IssueFieldText: :text,
14
+ IssueFieldNumber: :number,
15
+ IssueFieldDate: :date,
16
+ IssueFieldSingleSelect: :single_select,
17
+ }.freeze
18
+
19
+ # @return [String] GraphQL node ID, e.g. +"IFSS_kgDOAAGskA"+
20
+ attr_reader :id
21
+
22
+ # @return [String] display name (e.g. +"Priority"+)
23
+ attr_reader :name
24
+
25
+ # @return [Symbol] one of +:text+, +:number+, +:date+, +:single_select+
26
+ attr_reader :type
27
+
28
+ # @return [String, nil]
29
+ attr_reader :description
30
+
31
+ # @return [Array<Hash>] for +:single_select+, the option list as returned by GraphQL with symbol keys
32
+ # (+id+, +name+, +description+, +color+). Empty for other field types.
33
+ attr_reader :options
34
+
35
+ class << self
36
+ # Lists Issue Field definitions configured on the org.
37
+ #
38
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] if +config.issue_fields_enabled+ is +false+
39
+ #
40
+ # @param org [String, nil] org login; defaults to +config.organization+
41
+ #
42
+ # @return [Array<PlanMyStuff::IssueField>]
43
+ #
44
+ def list(org: nil)
45
+ ensure_enabled!
46
+
47
+ org_login = org || PlanMyStuff.configuration.organization
48
+ data = PlanMyStuff.client.graphql(
49
+ PlanMyStuff::GraphQL::Queries::LIST_ORG_ISSUE_FIELDS,
50
+ variables: { org: org_login },
51
+ )
52
+ Array.wrap(data.dig(:organization, :issueFields, :nodes)).map { |node| from_graphql(node) }
53
+ end
54
+
55
+ # @param name [String, Symbol]
56
+ # @param org [String, nil]
57
+ #
58
+ # @return [PlanMyStuff::IssueField, nil]
59
+ #
60
+ def find(name, org: nil)
61
+ list(org: org).find { |field| field.name.casecmp?(name.to_s) }
62
+ end
63
+
64
+ # @param node [Hash] one node from +LIST_ORG_ISSUE_FIELDS+
65
+ #
66
+ # @return [PlanMyStuff::IssueField]
67
+ #
68
+ def from_graphql(node)
69
+ typename = node[:__typename]
70
+ type = TYPES[typename.to_sym] if typename
71
+ raise(PlanMyStuff::Error, "Unknown Issue Field typename: #{typename.inspect}") if type.nil?
72
+
73
+ new(
74
+ id: node.fetch(:id),
75
+ name: node.fetch(:name),
76
+ type: type,
77
+ description: node[:description],
78
+ options: Array.wrap(node[:options]),
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError]
85
+ #
86
+ # @return [void]
87
+ #
88
+ def ensure_enabled!
89
+ return if PlanMyStuff.configuration.issue_fields_enabled
90
+
91
+ raise(PlanMyStuff::IssueFieldsNotEnabledError)
92
+ end
93
+ end
94
+
95
+ # @param id [String]
96
+ # @param name [String]
97
+ # @param type [Symbol]
98
+ # @param description [String, nil]
99
+ # @param options [Array<Hash>]
100
+ #
101
+ def initialize(id:, name:, type:, description: nil, options: [])
102
+ @id = id
103
+ @name = name
104
+ @type = type
105
+ @description = description
106
+ @options = options
107
+ end
108
+
109
+ # Resolves a single-select option name to its GraphQL node ID.
110
+ #
111
+ # @raise [PlanMyStuff::Error] if this field is not a single-select, or the option name is unknown
112
+ #
113
+ # @param option_name [String, Symbol]
114
+ #
115
+ # @return [String]
116
+ #
117
+ def option_id_for!(option_name)
118
+ raise(PlanMyStuff::Error, "Field #{name.inspect} is not a single-select") unless type == :single_select
119
+
120
+ match = options.find { |option| option.fetch(:name).casecmp?(option_name.to_s) }
121
+ raise(PlanMyStuff::Error, "Unknown option #{option_name.inspect} for field #{name.inspect}") if match.nil?
122
+
123
+ match.fetch(:id)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Translates between the canonical Issue Field names / values the gem refers to internally (e.g. +"Issue Status"+,
5
+ # +"Waiting on Reply"+) and the names / values a consuming org actually uses on GitHub. Driven by
6
+ # +config.issue_field_names+ (canonical field name => consumer field name) and +config.issue_field_values+
7
+ # (canonical field name => { canonical value => consumer value }).
8
+ #
9
+ # Outbound (canonical -> consumer) translation happens on writes and filters; inbound (consumer -> canonical) on
10
+ # reads, so internal comparisons like +issue_fields['Issue Status'] == 'Waiting on Reply'+ keep working regardless of
11
+ # how the org named the field or option. Unconfigured names / values pass through unchanged (identity fallback,
12
+ # mirroring +config.pipeline_statuses+).
13
+ module IssueFieldTranslation
14
+ module_function
15
+
16
+ # @param canonical [String, Symbol] canonical field name
17
+ #
18
+ # @return [String] the consumer's field name, or the canonical name when unmapped
19
+ #
20
+ def consumer_field_name(canonical)
21
+ PlanMyStuff.configuration.issue_field_names.fetch(canonical.to_s, canonical.to_s)
22
+ end
23
+
24
+ # @param consumer [String] the consumer's field name (as GitHub returns it)
25
+ #
26
+ # @return [String] the canonical field name, or the consumer name when unmapped
27
+ #
28
+ def canonical_field_name(consumer)
29
+ PlanMyStuff.configuration.issue_field_names.invert.fetch(consumer, consumer)
30
+ end
31
+
32
+ # Translates a canonical value to the consumer's value for the given canonical field. Non-String values (numbers,
33
+ # dates, +nil+) pass through untouched -- only single-select / text labels are translatable.
34
+ #
35
+ # @param canonical_field [String, Symbol] canonical field name
36
+ # @param value [Object, nil] canonical value
37
+ #
38
+ # @return [Object, nil] the consumer's value, or the input when unmapped / non-String
39
+ #
40
+ def consumer_value(canonical_field, value)
41
+ return value unless value.is_a?(String)
42
+
43
+ value_map(canonical_field).fetch(value, value)
44
+ end
45
+
46
+ # Translates a consumer value back to the canonical value for the given canonical field.
47
+ #
48
+ # @param canonical_field [String, Symbol] canonical field name
49
+ # @param value [Object, nil] consumer value
50
+ #
51
+ # @return [Object, nil] the canonical value, or the input when unmapped / non-String
52
+ #
53
+ def canonical_value(canonical_field, value)
54
+ return value unless value.is_a?(String)
55
+
56
+ value_map(canonical_field).invert.fetch(value, value)
57
+ end
58
+
59
+ # @param canonical_field [String, Symbol]
60
+ #
61
+ # @return [Hash{String => String}] canonical-value => consumer-value map for the field (empty when unconfigured)
62
+ #
63
+ def value_map(canonical_field)
64
+ PlanMyStuff.configuration.issue_field_values.fetch(canonical_field.to_s, {})
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ # Hash-like read-side view of GitHub Issue Field values on a single +Issue+. Returned by
5
+ # +Issue#issue_fields+. Values are coerced into Ruby types on construction: date fields come back
6
+ # as +Date+, number fields as +Float+, single-select fields as the option name +String+, and
7
+ # text fields as the raw +String+.
8
+ #
9
+ # Access is by canonical field name (consumer names / values are reverse-translated via
10
+ # +PlanMyStuff::IssueFieldTranslation+ on construction); string and symbol keys both work. Iteration yields
11
+ # +[name, value]+ pairs in the order GitHub returned them.
12
+ class IssueFieldValueSet
13
+ include Enumerable
14
+
15
+ delegate :empty?, to: :@hash
16
+
17
+ class << self
18
+ # @param nodes [Array<Hash>, nil] +issueFieldValues.nodes+ from the GraphQL read query
19
+ #
20
+ # @return [PlanMyStuff::IssueFieldValueSet]
21
+ #
22
+ def from_graphql(nodes)
23
+ pairs = Array.wrap(nodes).map do |node|
24
+ canonical_name = PlanMyStuff::IssueFieldTranslation.canonical_field_name(node.dig(:field, :name))
25
+ [canonical_name, PlanMyStuff::IssueFieldTranslation.canonical_value(canonical_name, coerce(node))]
26
+ end
27
+ new(pairs.to_h)
28
+ end
29
+
30
+ # @param node [Hash]
31
+ #
32
+ # @return [Object]
33
+ #
34
+ def coerce(node)
35
+ case node[:__typename].to_s
36
+ when 'IssueFieldDateValue' then Date.parse(node.fetch(:value))
37
+ when 'IssueFieldNumberValue' then node.fetch(:value).to_f
38
+ when 'IssueFieldSingleSelectValue' then node.fetch(:name)
39
+ else node.fetch(:value)
40
+ end
41
+ end
42
+ end
43
+
44
+ # @param hash [Hash{String => Object}]
45
+ #
46
+ def initialize(hash)
47
+ @hash = hash
48
+ end
49
+
50
+ # @param name [String, Symbol] field display name
51
+ #
52
+ # @return [Object, nil]
53
+ #
54
+ def [](name)
55
+ @hash[name.to_s]
56
+ end
57
+
58
+ # @return [Hash{String => Object}] copy of the underlying hash
59
+ def to_h
60
+ @hash.dup
61
+ end
62
+
63
+ # @return [Enumerator, void]
64
+ def each(&)
65
+ @hash.each(&)
66
+ end
67
+ end
68
+ end
@@ -1,17 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlanMyStuff
4
- class IssueMetadata < BaseMetadata
4
+ class IssueMetadata < PlanMyStuff::BaseMetadata
5
5
  # @return [Time, nil] first support action timestamp, nil until set
6
6
  attr_accessor :responded_at
7
7
  # @return [String, nil] user-facing URL in the consuming app
8
8
  attr_accessor :issues_url
9
- # @return [Boolean] whether this issue appears on the priority dashboard
10
- attr_accessor :priority_list
11
- # @return [Integer] sort order on priority dashboard (-1 = unranked)
12
- attr_accessor :priority_list_priority
13
9
  # @return [Array<Integer>] user IDs of non-support users allowed to view internal comments
14
10
  attr_accessor :visibility_allowlist
11
+ # @return [String, nil] merged PR commit SHA for release tracking
12
+ attr_accessor :commit_sha
13
+ # @return [Boolean] whether to auto-complete on deployment (default: true)
14
+ attr_accessor :auto_complete
15
+ # @return [Array<PlanMyStuff::Link>] metadata-backed issue relationships.
16
+ # Only +:related+ links live here; native relationships (blocking,
17
+ # parent, sub_ticket, duplicate_of) live on GitHub.
18
+ attr_accessor :links
19
+ # @return [Array<PlanMyStuff::Approval>] manager approvals required on
20
+ # this issue. See +Issue.request_approvals!+ and +Issue#fully_approved?+.
21
+ attr_accessor :approvals
22
+ # @return [Time, nil] when the issue entered "waiting on user reply" state
23
+ attr_accessor :waiting_on_user_at
24
+ # @return [Time, nil] when the issue entered "waiting on approval" state
25
+ attr_accessor :waiting_on_approval_at
26
+ # @return [Time, nil] when the next reminder event should fire for this issue
27
+ attr_accessor :next_reminder_at
28
+ # @return [Array<Integer>, nil] per-issue override of +config.reminder_days+;
29
+ # applies to both waiting kinds
30
+ attr_accessor :reminder_days
31
+ # @return [Boolean] whether this issue was auto-closed by the inactivity sweep
32
+ attr_accessor :closed_by_inactivity
33
+ # @return [Time, nil] when the archive sweep tagged this issue as archived
34
+ attr_accessor :archived_at
15
35
 
16
36
  class << self
17
37
  # Builds an IssueMetadata from a parsed hash (e.g. from MetadataParser)
@@ -22,13 +42,21 @@ module PlanMyStuff
22
42
  #
23
43
  def from_hash(hash)
24
44
  metadata = new
25
- apply_common_from_hash(metadata, hash)
45
+ apply_common_from_hash(metadata, hash, PlanMyStuff.configuration.custom_fields_for(:issue))
26
46
 
27
47
  metadata.responded_at = parse_time(hash[:responded_at])
28
48
  metadata.issues_url = hash[:issues_url]
29
- metadata.priority_list = hash.fetch(:priority_list, false)
30
- metadata.priority_list_priority = hash.fetch(:priority_list_priority, -1)
31
49
  metadata.visibility_allowlist = Array.wrap(hash[:visibility_allowlist])
50
+ metadata.commit_sha = hash[:commit_sha]
51
+ metadata.auto_complete = hash.fetch(:auto_complete, true)
52
+ metadata.links = normalize_links(hash[:links])
53
+ metadata.approvals = normalize_approvals(hash[:approvals])
54
+ metadata.waiting_on_user_at = parse_time(hash[:waiting_on_user_at])
55
+ metadata.waiting_on_approval_at = parse_time(hash[:waiting_on_approval_at])
56
+ metadata.next_reminder_at = parse_time(hash[:next_reminder_at])
57
+ metadata.reminder_days = normalize_reminder_days(hash[:reminder_days])
58
+ metadata.closed_by_inactivity = hash.fetch(:closed_by_inactivity, false)
59
+ metadata.archived_at = parse_time(hash[:archived_at])
32
60
 
33
61
  metadata
34
62
  end
@@ -42,13 +70,27 @@ module PlanMyStuff
42
70
  #
43
71
  def build(user:, visibility: 'public', custom_fields: {})
44
72
  metadata = new
45
- apply_common_build(metadata, user: user, visibility: visibility, custom_fields_data: custom_fields)
73
+ apply_common_build(
74
+ metadata,
75
+ user: user,
76
+ visibility: visibility,
77
+ custom_fields_data: custom_fields,
78
+ custom_fields_schema: PlanMyStuff.configuration.custom_fields_for(:issue),
79
+ )
46
80
 
47
81
  metadata.responded_at = nil
48
82
  metadata.issues_url = build_issues_url(PlanMyStuff.configuration)
49
- metadata.priority_list = false
50
- metadata.priority_list_priority = -1
51
83
  metadata.visibility_allowlist = []
84
+ metadata.commit_sha = nil
85
+ metadata.auto_complete = true
86
+ metadata.links = []
87
+ metadata.approvals = []
88
+ metadata.waiting_on_user_at = nil
89
+ metadata.waiting_on_approval_at = nil
90
+ metadata.next_reminder_at = nil
91
+ metadata.reminder_days = nil
92
+ metadata.closed_by_inactivity = false
93
+ metadata.archived_at = nil
52
94
 
53
95
  metadata
54
96
  end
@@ -61,18 +103,79 @@ module PlanMyStuff
61
103
 
62
104
  config.issues_url_prefix.to_s
63
105
  end
106
+
107
+ # Builds a +PlanMyStuff::Link+ from each parsed entry. Malformed
108
+ # entries (wrong shape, missing fields, invalid values) are
109
+ # silently dropped so a single bad entry doesn't crash
110
+ # +Issue.find+ for an otherwise healthy issue.
111
+ #
112
+ # @param raw [Array, nil]
113
+ #
114
+ # @return [Array<PlanMyStuff::Link>]
115
+ #
116
+ def normalize_links(raw)
117
+ Array.wrap(raw).filter_map do |entry|
118
+ PlanMyStuff::Link.build!(entry)
119
+ rescue ActiveModel::ValidationError, ArgumentError
120
+ next
121
+ end
122
+ end
123
+
124
+ # Builds a +PlanMyStuff::Approval+ from each parsed entry.
125
+ # Malformed entries (wrong shape, missing fields, invalid values,
126
+ # unknown attributes) are silently dropped so a single bad entry
127
+ # doesn't crash +Issue.find+ for an otherwise healthy issue.
128
+ #
129
+ # @param raw [Array, nil]
130
+ #
131
+ # @return [Array<PlanMyStuff::Approval>]
132
+ #
133
+ def normalize_approvals(raw)
134
+ Array.wrap(raw).filter_map do |entry|
135
+ approval = PlanMyStuff::Approval.new(entry.transform_keys(&:to_sym))
136
+ approval.validate!
137
+ approval
138
+ rescue ActiveModel::ValidationError, ArgumentError, NoMethodError
139
+ next
140
+ end
141
+ end
142
+
143
+ # Normalizes a raw +reminder_days+ value from parsed metadata.
144
+ # Returns +nil+ when absent so callers can fall back to config;
145
+ # otherwise returns the array with non-integer entries dropped.
146
+ #
147
+ # @param raw [Object]
148
+ #
149
+ # @return [Array<Integer>, nil]
150
+ #
151
+ def normalize_reminder_days(raw)
152
+ return if raw.nil?
153
+
154
+ Array.wrap(raw).filter_map do |entry|
155
+ Integer(entry)
156
+ rescue ArgumentError, TypeError
157
+ next
158
+ end
159
+ end
64
160
  end
65
161
 
66
162
  def initialize
67
163
  super
68
- @priority_list = false
69
- @priority_list_priority = -1
70
164
  @visibility_allowlist = []
165
+ @auto_complete = true
166
+ @links = []
167
+ @approvals = []
168
+ @waiting_on_user_at = nil
169
+ @waiting_on_approval_at = nil
170
+ @next_reminder_at = nil
171
+ @reminder_days = nil
172
+ @closed_by_inactivity = false
173
+ @archived_at = nil
71
174
  end
72
175
 
73
176
  # @return [Boolean]
74
- def priority_list?
75
- !!priority_list
177
+ def auto_complete?
178
+ !!auto_complete
76
179
  end
77
180
 
78
181
  # @return [Boolean]
@@ -91,20 +194,28 @@ module PlanMyStuff
91
194
  def visible_to?(user)
92
195
  return true if public?
93
196
 
94
- resolved = UserResolver.resolve(user)
95
- return true if UserResolver.support?(resolved)
197
+ resolved = PlanMyStuff::UserResolver.resolve(user)
198
+ return true if PlanMyStuff::UserResolver.support?(resolved)
96
199
 
97
- visibility_allowlist.include?(UserResolver.user_id(resolved))
200
+ visibility_allowlist.include?(PlanMyStuff::UserResolver.user_id(resolved))
98
201
  end
99
202
 
100
203
  # @return [Hash]
101
204
  def to_h
102
205
  super.merge(
103
- responded_at: format_time(responded_at),
206
+ responded_at: PlanMyStuff.format_time(responded_at),
104
207
  issues_url: issues_url,
105
- priority_list: priority_list,
106
- priority_list_priority: priority_list_priority,
107
208
  visibility_allowlist: visibility_allowlist,
209
+ commit_sha: commit_sha,
210
+ auto_complete: auto_complete,
211
+ links: links.map(&:to_h),
212
+ approvals: approvals.map(&:to_h),
213
+ waiting_on_user_at: PlanMyStuff.format_time(waiting_on_user_at),
214
+ waiting_on_approval_at: PlanMyStuff.format_time(waiting_on_approval_at),
215
+ next_reminder_at: PlanMyStuff.format_time(next_reminder_at),
216
+ reminder_days: reminder_days,
217
+ closed_by_inactivity: closed_by_inactivity,
218
+ archived_at: PlanMyStuff.format_time(archived_at),
108
219
  )
109
220
  end
110
221
  end