plan_my_stuff 0.11.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a34c14adba5975eb4e685f1685c6f4e4cf860cb8013a30b529839de03f87cd19
4
- data.tar.gz: ed239a56d7e52e6def2abd9e28929b49be7763945b84c3e3a4384ca2112f187c
3
+ metadata.gz: caa7ca14656d70850ad30923738ffa58177caf5fb91bd14bc6551f547590ca63
4
+ data.tar.gz: 1d4b79cc957efa2c146ffb2a433a90c798680b23da7b8907d9351b5cb041cc09
5
5
  SHA512:
6
- metadata.gz: e779fe0ceff48e299512f96905e0e5b7e9e75b6047a4324b7657b77d4c2a2b21ba6e213d52d72feccb4477930327a3d3d3d9e89f5cb24f2c0502c6a256fd6ef4
7
- data.tar.gz: e6242bd7a54f8861aca8d341ab17b569120c6acad8886e10b325f037b211b500939bbc941458a3756b54458bd43961588806129de9aa5718f8a99c6daa393f4d
6
+ metadata.gz: fd8f13707987c75445038cf93240be65897b00c538d3500665738554aa5ee3a6680352a2bb932e1fab41ba24e58985606655bd2f34e042375bc8a7692cde7310
7
+ data.tar.gz: 4b2160e17a7043004f294b7c6ae5c46f25f6a68ed5ca65ec5c3a124bfd34260521d1d07a6d22152e341f1d2ed8d6f1b79e729f6c9d39f8c7c0a907803baa017e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Changed
6
+
7
+ - Internal: gem now autoloads `lib/plan_my_stuff/**` via Zeitwerk
8
+ (already shipped by railties - no new dep). All `require_relative`
9
+ boilerplate in the gem's lib tree is gone. The gem entry retains two
10
+ explicit `require_relative`s for files that fall outside the autoload
11
+ model: `errors.rb` (defines several sibling error classes) and
12
+ `engine.rb` (must register with Rails at load time).
13
+
14
+ ## 0.12.0
15
+
16
+ ### Added
17
+
18
+ - `PlanMyStuff::IssueField` for org-level GitHub Issue Field definitions (public preview).
19
+ Exposes `.list`, `.find` (case-insensitive), and `#option_id_for!` for resolving
20
+ single-select option names.
21
+ - `PlanMyStuff::Issue#issue_fields` returns a hash-like `IssueFieldValueSet` view of the
22
+ field values on an issue. Values are coerced to native types (`Date` for date fields,
23
+ `Float` for numbers, the option name `String` for single-selects).
24
+ - `PlanMyStuff::Issue#set_issue_fields!(updates)` writes one or more field values in a
25
+ single GraphQL mutation; passing `nil` as a value clears the field.
26
+ - `issue_fields:` kwarg on `Issue.create!`, `Issue.update!`, `Issue#save!`, and
27
+ `Issue#update!` so callers can set field values inline with create/update instead of
28
+ following up with an explicit `set_issue_fields!` call.
29
+ - `Configuration#issue_fields_enabled` (default `true`, opt-out). Set to `false` if your
30
+ org has not been admitted to the Issue Fields preview - with the flag off,
31
+ `Issue#issue_fields` returns an empty set and the write paths raise
32
+ `IssueFieldsNotEnabledError` instead of letting a raw GraphQL error surface.
33
+
3
34
  ## 0.11.0
4
35
 
5
36
  ### Added
data/CONFIGURATION.md CHANGED
@@ -192,6 +192,26 @@ config.issue_types = {
192
192
  }
193
193
  ```
194
194
 
195
+ ## Issue Fields (public preview)
196
+
197
+ | Option | Type | Default | Description |
198
+ |---|---|---|---|
199
+ | `issue_fields_enabled` | `Boolean` | `true` | Whether the Issue Fields public preview is wired up for the org. |
200
+
201
+ GitHub Issue Fields are structured per-issue metadata (text, number, date, or single-select)
202
+ configured once at the org level. The preview is rolling out org-by-org. Leave this flag at its
203
+ default (`true`) once your org has been admitted; flip to `false` to keep the gem from issuing
204
+ calls that would otherwise return raw GraphQL errors.
205
+
206
+ With the flag off:
207
+
208
+ - `Issue#issue_fields` returns an empty `IssueFieldValueSet` without making a request.
209
+ - `Issue#set_issue_fields!(...)` and `IssueField.list` raise `IssueFieldsNotEnabledError`.
210
+
211
+ ```ruby
212
+ config.issue_fields_enabled = false # org not admitted to the preview yet
213
+ ```
214
+
195
215
  ## Release pipeline
196
216
 
197
217
  | Option | Type | Default | Description |
@@ -168,6 +168,16 @@ PlanMyStuff.configure do |config|
168
168
  # 'Feature' => 'Enhancement',
169
169
  # }
170
170
 
171
+ # --------------------------------------------------------------------------
172
+ # Issue Fields (public preview)
173
+ # --------------------------------------------------------------------------
174
+ # GitHub Issue Fields is a per-org public preview. Default is true (opt-out).
175
+ # Flip to false if your org has not been admitted to the preview - read paths
176
+ # return an empty set and write paths raise IssueFieldsNotEnabledError instead
177
+ # of letting a raw GraphQL error bubble.
178
+ #
179
+ # config.issue_fields_enabled = false
180
+
171
181
  # --------------------------------------------------------------------------
172
182
  # Release pipeline
173
183
  # --------------------------------------------------------------------------
@@ -10,5 +10,3 @@ module PlanMyStuff
10
10
  module Archive
11
11
  end
12
12
  end
13
-
14
- require_relative 'archive/sweep'
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'base_project_extractions/graphql_hydration'
4
-
5
3
  module PlanMyStuff
6
4
  # Shared base for GitHub Projects V2 wrappers. Holds attribute definitions, generic find/list/update machinery,
7
5
  # hydration, and instance helpers. Concrete subclasses (Project, TestingProject) add their own +create!+ behavior
@@ -356,6 +356,15 @@ module PlanMyStuff
356
356
  #
357
357
  attr_accessor :attachment_repo
358
358
 
359
+ # Whether GitHub's Issue Fields (public preview) are wired up for the configured org. Defaults to +true+ (opt-out):
360
+ # when +false+, +Issue#issue_fields+ returns an empty +IssueFieldValueSet+ without making a request and
361
+ # +Issue#set_issue_fields!+ / +IssueField.list+ raise +IssueFieldsNotEnabledError+. Set to +false+ if your org has
362
+ # not been admitted to the Issue Fields preview, to avoid raw GraphQL errors from GitHub.
363
+ #
364
+ # @return [Boolean]
365
+ #
366
+ attr_accessor :issue_fields_enabled
367
+
359
368
  # @return [Configuration]
360
369
  def initialize
361
370
  @repos = {}
@@ -394,6 +403,7 @@ module PlanMyStuff
394
403
  @archived_label = 'archived'
395
404
  @pipeline_completion_purge_enabled = true
396
405
  @pipeline_completion_ttl_hours = 24
406
+ @issue_fields_enabled = true
397
407
  @process_aws_webhooks = Rails.env.production?
398
408
  @sns_verifier_class = ::Aws::SNS::MessageVerifier if defined?(::Aws::SNS::MessageVerifier)
399
409
  @sns_verifier_error =
@@ -106,6 +106,17 @@ module PlanMyStuff
106
106
  class LockedIssueError < PlanMyStuff::Error
107
107
  end
108
108
 
109
+ # Raised when an Issue Fields API call is attempted while
110
+ # +config.issue_fields_enabled+ is +false+. Consumers whose org has not been
111
+ # admitted to the Issue Fields public preview flip the flag off; this error
112
+ # surfaces faster (and with a clearer message) than the underlying
113
+ # +GraphQLError+ that GitHub would otherwise return.
114
+ class IssueFieldsNotEnabledError < PlanMyStuff::Error
115
+ def initialize(message = nil)
116
+ super(message || 'Issue Fields are disabled; set config.issue_fields_enabled = true to enable')
117
+ end
118
+ end
119
+
109
120
  # Raised by +PlanMyStuff::Pipeline+ forward transitions when the linked
110
121
  # +Issue+ has any pending manager approvals.
111
122
  class PendingApprovalsError < PlanMyStuff::ValidationError
@@ -399,6 +399,56 @@ module PlanMyStuff
399
399
  }
400
400
  }
401
401
  GRAPHQL
402
+
403
+ # --- Issue Fields (org-level public preview) -----------------------
404
+
405
+ LIST_ORG_ISSUE_FIELDS = <<~GRAPHQL
406
+ query($org: String!) {
407
+ organization(login: $org) {
408
+ issueFields(first: 50) {
409
+ nodes {
410
+ __typename
411
+ ... on IssueFieldText { id name description }
412
+ ... on IssueFieldNumber { id name description }
413
+ ... on IssueFieldDate { id name description }
414
+ ... on IssueFieldSingleSelect {
415
+ id name description
416
+ options { id name description color }
417
+ }
418
+ }
419
+ }
420
+ }
421
+ }
422
+ GRAPHQL
423
+
424
+ READ_ISSUE_FIELD_VALUES = <<~GRAPHQL
425
+ query($owner: String!, $name: String!, $number: Int!) {
426
+ repository(owner: $owner, name: $name) {
427
+ issue(number: $number) {
428
+ issueFieldValues(first: 50) {
429
+ nodes {
430
+ __typename
431
+ ... on IssueFieldTextValue { value field { ... on IssueFieldText { id name } } }
432
+ ... on IssueFieldNumberValue { value field { ... on IssueFieldNumber { id name } } }
433
+ ... on IssueFieldDateValue { value field { ... on IssueFieldDate { id name } } }
434
+ ... on IssueFieldSingleSelectValue {
435
+ name optionId
436
+ field { ... on IssueFieldSingleSelect { id name } }
437
+ }
438
+ }
439
+ }
440
+ }
441
+ }
442
+ }
443
+ GRAPHQL
444
+
445
+ SET_ISSUE_FIELD_VALUES = <<~GRAPHQL
446
+ mutation($issueId: ID!, $issueFields: [IssueFieldCreateOrUpdateInput!]!) {
447
+ setIssueFieldValue(input: { issueId: $issueId, issueFields: $issueFields }) {
448
+ issue { number }
449
+ }
450
+ }
451
+ GRAPHQL
402
452
  end
403
453
  end
404
454
  end
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'issue_extractions/approvals'
4
- require_relative 'issue_extractions/links'
5
- require_relative 'issue_extractions/viewers'
6
- require_relative 'issue_extractions/waiting'
7
-
8
3
  module PlanMyStuff
9
4
  # Wraps a GitHub issue with parsed PMS metadata and comments.
10
5
  # Class methods provide the public API for CRUD operations.
@@ -84,6 +79,9 @@ module PlanMyStuff
84
79
  # @param visibility_allowlist [Array<Integer>] user IDs for internal comment access
85
80
  # @param issue_type [String, nil] GitHub issue type name (e.g. +"Bug"+, +"Feature"+). Must match a type
86
81
  # configured on the org. +nil+ creates the issue with no type.
82
+ # @param issue_fields [Hash{String,Symbol => Object,nil}, nil] GitHub Issue Field values to apply after the
83
+ # issue is created. Delegates to +#set_issue_fields!+, so the same coercion rules and
84
+ # +IssueFieldsNotEnabledError+ behavior apply. +nil+ or an empty hash is a no-op.
87
85
  # @param attachments [Array] files to upload to +config.attachment_repo+ and record on the body comment. Each
88
86
  # entry may be an uploaded-file object responding to +#path+ and +#original_filename+ (e.g. Rails
89
87
  # +ActionDispatch::Http::UploadedFile+), a String/Pathname path to a local file, or a pre-built
@@ -103,8 +101,13 @@ module PlanMyStuff
103
101
  visibility: 'public',
104
102
  visibility_allowlist: [],
105
103
  issue_type: nil,
104
+ issue_fields: nil,
106
105
  attachments: []
107
106
  )
107
+ if issue_fields.present? && !PlanMyStuff.configuration.issue_fields_enabled
108
+ raise(PlanMyStuff::IssueFieldsNotEnabledError)
109
+ end
110
+
108
111
  if body.blank?
109
112
  raise(PlanMyStuff::ValidationError.new('body must be present', field: :body, expected_type: :string))
110
113
  end
@@ -160,6 +163,8 @@ module PlanMyStuff
160
163
  attachments: attachments,
161
164
  )
162
165
 
166
+ issue.set_issue_fields!(issue_fields) if issue_fields.present?
167
+
163
168
  issue.reload
164
169
  PlanMyStuff::Notifications.instrument('issue.created', issue, user: user)
165
170
  issue
@@ -187,6 +192,9 @@ module PlanMyStuff
187
192
  # @param issue_type [String, nil] GitHub issue type name. Pass a String to set, +nil+ to clear, or omit the
188
193
  # kwarg to leave the current type untouched. (+nil+-vs-omitted is differentiated by the private
189
194
  # +ISSUE_TYPE_UNCHANGED+ sentinel.)
195
+ # @param issue_fields [Hash{String,Symbol => Object,nil}, nil] GitHub Issue Field values to apply after the
196
+ # PATCH (or instead of it, when no other attrs are provided). Delegates to +#set_issue_fields!+, so the same
197
+ # coercion rules and +IssueFieldsNotEnabledError+ behavior apply. +nil+ or an empty hash is a no-op.
190
198
  #
191
199
  # @return [Object]
192
200
  #
@@ -199,7 +207,8 @@ module PlanMyStuff
199
207
  labels: nil,
200
208
  state: nil,
201
209
  assignees: nil,
202
- issue_type: ISSUE_TYPE_UNCHANGED
210
+ issue_type: ISSUE_TYPE_UNCHANGED,
211
+ issue_fields: nil
203
212
  )
204
213
  client = PlanMyStuff.client
205
214
  resolved_repo = client.resolve_repo!(repo)
@@ -236,7 +245,8 @@ module PlanMyStuff
236
245
 
237
246
  update_body_comment!(number, resolved_repo, body) if body
238
247
 
239
- return if options.none?
248
+ updated_issue = find(number, repo: resolved_repo).set_issue_fields!(issue_fields) if issue_fields.present?
249
+ return updated_issue if options.none?
240
250
 
241
251
  result = client.rest(:update_issue, resolved_repo, number, **options)
242
252
  store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
@@ -557,6 +567,7 @@ module PlanMyStuff
557
567
  visibility: metadata.visibility,
558
568
  visibility_allowlist: Array.wrap(metadata.visibility_allowlist),
559
569
  issue_type: issue_type,
570
+ issue_fields: @pending_issue_fields,
560
571
  )
561
572
  hydrate_from_issue(created)
562
573
  else
@@ -565,6 +576,7 @@ module PlanMyStuff
565
576
  instrument_update(captured_changes, user) unless skip_notification
566
577
  end
567
578
 
579
+ @pending_issue_fields = nil
568
580
  self
569
581
  end
570
582
 
@@ -666,6 +678,42 @@ module PlanMyStuff
666
678
  safe_read_field(github_response, :id)
667
679
  end
668
680
 
681
+ # Returns a hash-like view of GitHub Issue Field values currently set on this issue. Reads on first access and
682
+ # memoizes; +set_issue_fields!+ invalidates the cache. Returns an empty set without making a request when
683
+ # +config.issue_fields_enabled+ is +false+.
684
+ #
685
+ # @return [PlanMyStuff::IssueFieldValueSet]
686
+ #
687
+ def issue_fields
688
+ @issue_fields ||= load_issue_fields!
689
+ end
690
+
691
+ # Bulk-updates GitHub Issue Field values in a single +setIssueFieldValue+ mutation. Each key is the field display
692
+ # name; values are coerced to the right input fragment based on the field's type. Passing +nil+ as a value clears
693
+ # that field.
694
+ #
695
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] when +config.issue_fields_enabled+ is +false+
696
+ # @raise [PlanMyStuff::Error] when a referenced field name does not exist on the org
697
+ #
698
+ # @param updates [Hash{String,Symbol => Object,nil}]
699
+ #
700
+ # @return [self]
701
+ #
702
+ def set_issue_fields!(updates)
703
+ raise(PlanMyStuff::IssueFieldsNotEnabledError) unless PlanMyStuff.configuration.issue_fields_enabled
704
+
705
+ fields_by_name = PlanMyStuff::IssueField.list(org: repo.organization).index_by { |field| field.name.downcase }
706
+ inputs = updates.map { |name, value| build_issue_field_input(fields_by_name, name, value) }
707
+
708
+ PlanMyStuff.client.graphql(
709
+ PlanMyStuff::GraphQL::Queries::SET_ISSUE_FIELD_VALUES,
710
+ variables: { issueId: github_node_id, issueFields: inputs },
711
+ )
712
+
713
+ @issue_fields = nil
714
+ self
715
+ end
716
+
669
717
  private
670
718
 
671
719
  # Populates this instance from a GitHub API response.
@@ -695,6 +743,7 @@ module PlanMyStuff
695
743
  @body_dirty = false
696
744
  persisted!
697
745
  @comments = nil
746
+ @issue_fields = nil
698
747
  invalidate_links_cache!
699
748
  end
700
749
 
@@ -722,6 +771,7 @@ module PlanMyStuff
722
771
  self.metadata = other.metadata
723
772
  persisted!
724
773
  @comments = nil
774
+ @issue_fields = nil
725
775
  invalidate_links_cache!
726
776
  end
727
777
 
@@ -764,6 +814,7 @@ module PlanMyStuff
764
814
  }
765
815
  attrs[:body] = body if @body_dirty
766
816
  attrs[:assignees] = @pending_assignees unless @pending_assignees.nil?
817
+ attrs[:issue_fields] = @pending_issue_fields if @pending_issue_fields.present?
767
818
  attrs[:issue_type] = issue_type if issue_type_changed?
768
819
 
769
820
  clear_waiting_state_on_close(attrs)
@@ -789,6 +840,7 @@ module PlanMyStuff
789
840
  self.body = attrs[:body] if attrs.key?(:body)
790
841
  self.issue_type = attrs[:issue_type] if attrs.key?(:issue_type)
791
842
  @pending_assignees = attrs[:assignees] if attrs.key?(:assignees)
843
+ @pending_issue_fields = attrs[:issue_fields] if attrs.key?(:issue_fields)
792
844
  apply_metadata_attrs(attrs[:metadata]) if attrs.key?(:metadata)
793
845
  end
794
846
 
@@ -905,5 +957,47 @@ module PlanMyStuff
905
957
 
906
958
  id
907
959
  end
960
+
961
+ # @return [PlanMyStuff::IssueFieldValueSet]
962
+ def load_issue_fields!
963
+ return PlanMyStuff::IssueFieldValueSet.new({}) unless PlanMyStuff.configuration.issue_fields_enabled
964
+
965
+ data = PlanMyStuff.client.graphql(
966
+ PlanMyStuff::GraphQL::Queries::READ_ISSUE_FIELD_VALUES,
967
+ variables: { owner: repo.organization, name: repo.name, number: number },
968
+ )
969
+ nodes = data.dig(:repository, :issue, :issueFieldValues, :nodes)
970
+ PlanMyStuff::IssueFieldValueSet.from_graphql(nodes)
971
+ end
972
+
973
+ # Builds one element of the +issueFields+ argument to the +setIssueFieldValue+ mutation. Looks up the field
974
+ # definition in the provided hash to pick the right input fragment and (for single-select) resolve the option ID.
975
+ #
976
+ # @raise [PlanMyStuff::Error] if the field name is unknown on the org
977
+ #
978
+ # @param fields_by_name [Hash{String => PlanMyStuff::IssueField}] fields keyed by downcased display name
979
+ # @param name [String, Symbol]
980
+ # @param value [Object, nil]
981
+ #
982
+ # @return [Hash]
983
+ #
984
+ def build_issue_field_input(fields_by_name, name, value)
985
+ field = fields_by_name[name.to_s.downcase]
986
+ raise(PlanMyStuff::Error, "Unknown Issue Field #{name.inspect}") if field.nil?
987
+
988
+ return { fieldId: field.id, delete: true } if value.nil?
989
+
990
+ case field.type
991
+ when :single_select then { fieldId: field.id, singleSelectOptionId: field.option_id_for!(value) }
992
+ when :date then { fieldId: field.id, dateValue: value.to_date.iso8601 }
993
+ when :number
994
+ unless value.is_a?(Numeric)
995
+ raise(PlanMyStuff::Error, "Issue Field #{name.inspect} expects Numeric, got #{value.inspect}")
996
+ end
997
+
998
+ { fieldId: field.id, numberValue: value.to_f }
999
+ when :text then { fieldId: field.id, textValue: value.to_s }
1000
+ end
1001
+ end
908
1002
  end
909
1003
  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,64 @@
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 field display name; string and symbol keys both work. Iteration yields
10
+ # +[name, value]+ pairs in the order GitHub returned them.
11
+ class IssueFieldValueSet
12
+ include Enumerable
13
+
14
+ delegate :empty?, to: :@hash
15
+
16
+ class << self
17
+ # @param nodes [Array<Hash>, nil] +issueFieldValues.nodes+ from the GraphQL read query
18
+ #
19
+ # @return [PlanMyStuff::IssueFieldValueSet]
20
+ #
21
+ def from_graphql(nodes)
22
+ pairs = Array.wrap(nodes).map { |node| [node.dig(:field, :name), coerce(node)] }
23
+ new(pairs.to_h)
24
+ end
25
+
26
+ # @param node [Hash]
27
+ #
28
+ # @return [Object]
29
+ #
30
+ def coerce(node)
31
+ case node[:__typename].to_s
32
+ when 'IssueFieldDateValue' then Date.parse(node.fetch(:value))
33
+ when 'IssueFieldNumberValue' then node.fetch(:value).to_f
34
+ when 'IssueFieldSingleSelectValue' then node.fetch(:name)
35
+ else node.fetch(:value)
36
+ end
37
+ end
38
+ end
39
+
40
+ # @param hash [Hash{String => Object}]
41
+ #
42
+ def initialize(hash)
43
+ @hash = hash
44
+ end
45
+
46
+ # @param name [String, Symbol] field display name
47
+ #
48
+ # @return [Object, nil]
49
+ #
50
+ def [](name)
51
+ @hash[name.to_s]
52
+ end
53
+
54
+ # @return [Hash{String => Object}] copy of the underlying hash
55
+ def to_h
56
+ @hash.dup
57
+ end
58
+
59
+ # @return [Enumerator, void]
60
+ def each(&)
61
+ @hash.each(&)
62
+ end
63
+ end
64
+ end
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'pipeline/completed_sweep'
4
- require_relative 'pipeline/issue_linker'
5
- require_relative 'pipeline/status'
6
- require_relative 'pipeline/testing'
7
-
8
3
  module PlanMyStuff
9
4
  # High-level orchestration layer for the release pipeline.
10
5
  #
@@ -10,7 +10,3 @@ module PlanMyStuff
10
10
  module Reminders
11
11
  end
12
12
  end
13
-
14
- require_relative 'reminders/closer'
15
- require_relative 'reminders/fire'
16
- require_relative 'reminders/sweep'
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 11
6
+ MINOR = 13
7
7
  TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
data/lib/plan_my_stuff.rb CHANGED
@@ -1,48 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'zeitwerk'
4
+
3
5
  require 'date'
4
6
 
5
7
  require 'active_support/core_ext/array/wrap'
6
8
  require 'active_support/core_ext/object/blank'
7
9
 
8
- require_relative 'plan_my_stuff/application_record'
9
- require_relative 'plan_my_stuff/approval'
10
- require_relative 'plan_my_stuff/archive'
11
- require_relative 'plan_my_stuff/attachment'
12
- require_relative 'plan_my_stuff/attachment_uploader'
13
- require_relative 'plan_my_stuff/base_metadata'
14
- require_relative 'plan_my_stuff/base_project'
15
- require_relative 'plan_my_stuff/base_project_item'
16
- require_relative 'plan_my_stuff/base_project_metadata'
17
- require_relative 'plan_my_stuff/cache'
18
- require_relative 'plan_my_stuff/client'
19
- require_relative 'plan_my_stuff/comment'
20
- require_relative 'plan_my_stuff/comment_metadata'
21
- require_relative 'plan_my_stuff/configuration'
22
- require_relative 'plan_my_stuff/custom_fields'
23
- require_relative 'plan_my_stuff/engine' if defined?(Rails)
10
+ loader = Zeitwerk::Loader.for_gem
11
+ loader.inflector.inflect(
12
+ 'graphql' => 'GraphQL',
13
+ 'version' => 'VERSION',
14
+ )
15
+ loader.ignore(
16
+ File.join(__dir__, 'generators'),
17
+ File.join(__dir__, 'tasks'),
18
+ File.join(__dir__, 'plan_my_stuff', 'aws_sns_simulator.rb'),
19
+ File.join(__dir__, 'plan_my_stuff', 'engine.rb'),
20
+ File.join(__dir__, 'plan_my_stuff', 'errors.rb'),
21
+ File.join(__dir__, 'plan_my_stuff', 'test_helpers.rb'),
22
+ File.join(__dir__, 'plan_my_stuff', 'webhook_replayer.rb'),
23
+ )
24
+ loader.setup
25
+
26
+ # errors.rb defines several sibling error classes - load it eagerly rather
27
+ # than rely on autoload-via-lead-constant.
24
28
  require_relative 'plan_my_stuff/errors'
25
- require_relative 'plan_my_stuff/graphql/queries'
26
- require_relative 'plan_my_stuff/issue'
27
- require_relative 'plan_my_stuff/issue_metadata'
28
- require_relative 'plan_my_stuff/label'
29
- require_relative 'plan_my_stuff/link'
30
- require_relative 'plan_my_stuff/markdown'
31
- require_relative 'plan_my_stuff/metadata_parser'
32
- require_relative 'plan_my_stuff/notifications'
33
- require_relative 'plan_my_stuff/pipeline'
34
- require_relative 'plan_my_stuff/project'
35
- require_relative 'plan_my_stuff/project_item'
36
- require_relative 'plan_my_stuff/project_item_metadata'
37
- require_relative 'plan_my_stuff/project_metadata'
38
- require_relative 'plan_my_stuff/reminders'
39
- require_relative 'plan_my_stuff/repo'
40
- require_relative 'plan_my_stuff/testing_project'
41
- require_relative 'plan_my_stuff/testing_project_item'
42
- require_relative 'plan_my_stuff/testing_project_metadata'
43
- require_relative 'plan_my_stuff/user_resolver'
44
- require_relative 'plan_my_stuff/verifier'
45
- require_relative 'plan_my_stuff/version'
29
+
30
+ # Engine must register with Rails at load time, so eagerly require it
31
+ # (and only when Rails is defined - otherwise Rails::Engine is undefined).
32
+ require_relative 'plan_my_stuff/engine' if defined?(Rails)
46
33
 
47
34
  module PlanMyStuff
48
35
  class << self
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plan_my_stuff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brands Insurance
@@ -132,6 +132,8 @@ files:
132
132
  - lib/plan_my_stuff/issue_extractions/links.rb
133
133
  - lib/plan_my_stuff/issue_extractions/viewers.rb
134
134
  - lib/plan_my_stuff/issue_extractions/waiting.rb
135
+ - lib/plan_my_stuff/issue_field.rb
136
+ - lib/plan_my_stuff/issue_field_value_set.rb
135
137
  - lib/plan_my_stuff/issue_metadata.rb
136
138
  - lib/plan_my_stuff/label.rb
137
139
  - lib/plan_my_stuff/link.rb