plan_my_stuff 0.11.0 → 0.12.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: d23d00fb8f2addae60d416beb76a19f81fdf0bb0ce5235453262317c6e351c94
4
+ data.tar.gz: a83cf20f6347ae27df34133d20b0b10646f5a96346616d0b5594ea4bd965c10f
5
5
  SHA512:
6
- metadata.gz: e779fe0ceff48e299512f96905e0e5b7e9e75b6047a4324b7657b77d4c2a2b21ba6e213d52d72feccb4477930327a3d3d3d9e89f5cb24f2c0502c6a256fd6ef4
7
- data.tar.gz: e6242bd7a54f8861aca8d341ab17b569120c6acad8886e10b325f037b211b500939bbc941458a3756b54458bd43961588806129de9aa5718f8a99c6daa393f4d
6
+ metadata.gz: b4cb5444db19f7a551f7296d96145db4e4be90d7897235abd877771dbbc15ffcd3361a4faade94fa521f7e491828c794854d2b0ac97b76e38e7c122f4fc63d07
7
+ data.tar.gz: a46060793225471181698152cf2681d8be3f64252a845aa2f9e0a838ffc1977f88c81f2b60b28507c8e8a7e7eb64e67016d163d7353d6c9dc9f5faa9b5f8e140
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Added
6
+
7
+ - `PlanMyStuff::IssueField` for org-level GitHub Issue Field definitions (public preview).
8
+ Exposes `.list`, `.find` (case-insensitive), and `#option_id_for!` for resolving
9
+ single-select option names.
10
+ - `PlanMyStuff::Issue#issue_fields` returns a hash-like `IssueFieldValueSet` view of the
11
+ field values on an issue. Values are coerced to native types (`Date` for date fields,
12
+ `Float` for numbers, the option name `String` for single-selects).
13
+ - `PlanMyStuff::Issue#set_issue_fields!(updates)` writes one or more field values in a
14
+ single GraphQL mutation; passing `nil` as a value clears the field.
15
+ - `issue_fields:` kwarg on `Issue.create!`, `Issue.update!`, `Issue#save!`, and
16
+ `Issue#update!` so callers can set field values inline with create/update instead of
17
+ following up with an explicit `set_issue_fields!` call.
18
+ - `Configuration#issue_fields_enabled` (default `true`, opt-out). Set to `false` if your
19
+ org has not been admitted to the Issue Fields preview - with the flag off,
20
+ `Issue#issue_fields` returns an empty set and the write paths raise
21
+ `IssueFieldsNotEnabledError` instead of letting a raw GraphQL error surface.
22
+
3
23
  ## 0.11.0
4
24
 
5
25
  ### 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
  # --------------------------------------------------------------------------
@@ -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
@@ -84,6 +84,9 @@ module PlanMyStuff
84
84
  # @param visibility_allowlist [Array<Integer>] user IDs for internal comment access
85
85
  # @param issue_type [String, nil] GitHub issue type name (e.g. +"Bug"+, +"Feature"+). Must match a type
86
86
  # configured on the org. +nil+ creates the issue with no type.
87
+ # @param issue_fields [Hash{String,Symbol => Object,nil}, nil] GitHub Issue Field values to apply after the
88
+ # issue is created. Delegates to +#set_issue_fields!+, so the same coercion rules and
89
+ # +IssueFieldsNotEnabledError+ behavior apply. +nil+ or an empty hash is a no-op.
87
90
  # @param attachments [Array] files to upload to +config.attachment_repo+ and record on the body comment. Each
88
91
  # entry may be an uploaded-file object responding to +#path+ and +#original_filename+ (e.g. Rails
89
92
  # +ActionDispatch::Http::UploadedFile+), a String/Pathname path to a local file, or a pre-built
@@ -103,8 +106,13 @@ module PlanMyStuff
103
106
  visibility: 'public',
104
107
  visibility_allowlist: [],
105
108
  issue_type: nil,
109
+ issue_fields: nil,
106
110
  attachments: []
107
111
  )
112
+ if issue_fields.present? && !PlanMyStuff.configuration.issue_fields_enabled
113
+ raise(PlanMyStuff::IssueFieldsNotEnabledError)
114
+ end
115
+
108
116
  if body.blank?
109
117
  raise(PlanMyStuff::ValidationError.new('body must be present', field: :body, expected_type: :string))
110
118
  end
@@ -160,6 +168,8 @@ module PlanMyStuff
160
168
  attachments: attachments,
161
169
  )
162
170
 
171
+ issue.set_issue_fields!(issue_fields) if issue_fields.present?
172
+
163
173
  issue.reload
164
174
  PlanMyStuff::Notifications.instrument('issue.created', issue, user: user)
165
175
  issue
@@ -187,6 +197,9 @@ module PlanMyStuff
187
197
  # @param issue_type [String, nil] GitHub issue type name. Pass a String to set, +nil+ to clear, or omit the
188
198
  # kwarg to leave the current type untouched. (+nil+-vs-omitted is differentiated by the private
189
199
  # +ISSUE_TYPE_UNCHANGED+ sentinel.)
200
+ # @param issue_fields [Hash{String,Symbol => Object,nil}, nil] GitHub Issue Field values to apply after the
201
+ # PATCH (or instead of it, when no other attrs are provided). Delegates to +#set_issue_fields!+, so the same
202
+ # coercion rules and +IssueFieldsNotEnabledError+ behavior apply. +nil+ or an empty hash is a no-op.
190
203
  #
191
204
  # @return [Object]
192
205
  #
@@ -199,7 +212,8 @@ module PlanMyStuff
199
212
  labels: nil,
200
213
  state: nil,
201
214
  assignees: nil,
202
- issue_type: ISSUE_TYPE_UNCHANGED
215
+ issue_type: ISSUE_TYPE_UNCHANGED,
216
+ issue_fields: nil
203
217
  )
204
218
  client = PlanMyStuff.client
205
219
  resolved_repo = client.resolve_repo!(repo)
@@ -236,7 +250,8 @@ module PlanMyStuff
236
250
 
237
251
  update_body_comment!(number, resolved_repo, body) if body
238
252
 
239
- return if options.none?
253
+ updated_issue = find(number, repo: resolved_repo).set_issue_fields!(issue_fields) if issue_fields.present?
254
+ return updated_issue if options.none?
240
255
 
241
256
  result = client.rest(:update_issue, resolved_repo, number, **options)
242
257
  store_etag_to_cache(client, resolved_repo, number, result, cache_writer: :write_issue)
@@ -557,6 +572,7 @@ module PlanMyStuff
557
572
  visibility: metadata.visibility,
558
573
  visibility_allowlist: Array.wrap(metadata.visibility_allowlist),
559
574
  issue_type: issue_type,
575
+ issue_fields: @pending_issue_fields,
560
576
  )
561
577
  hydrate_from_issue(created)
562
578
  else
@@ -565,6 +581,7 @@ module PlanMyStuff
565
581
  instrument_update(captured_changes, user) unless skip_notification
566
582
  end
567
583
 
584
+ @pending_issue_fields = nil
568
585
  self
569
586
  end
570
587
 
@@ -666,6 +683,42 @@ module PlanMyStuff
666
683
  safe_read_field(github_response, :id)
667
684
  end
668
685
 
686
+ # Returns a hash-like view of GitHub Issue Field values currently set on this issue. Reads on first access and
687
+ # memoizes; +set_issue_fields!+ invalidates the cache. Returns an empty set without making a request when
688
+ # +config.issue_fields_enabled+ is +false+.
689
+ #
690
+ # @return [PlanMyStuff::IssueFieldValueSet]
691
+ #
692
+ def issue_fields
693
+ @issue_fields ||= load_issue_fields!
694
+ end
695
+
696
+ # Bulk-updates GitHub Issue Field values in a single +setIssueFieldValue+ mutation. Each key is the field display
697
+ # name; values are coerced to the right input fragment based on the field's type. Passing +nil+ as a value clears
698
+ # that field.
699
+ #
700
+ # @raise [PlanMyStuff::IssueFieldsNotEnabledError] when +config.issue_fields_enabled+ is +false+
701
+ # @raise [PlanMyStuff::Error] when a referenced field name does not exist on the org
702
+ #
703
+ # @param updates [Hash{String,Symbol => Object,nil}]
704
+ #
705
+ # @return [self]
706
+ #
707
+ def set_issue_fields!(updates)
708
+ raise(PlanMyStuff::IssueFieldsNotEnabledError) unless PlanMyStuff.configuration.issue_fields_enabled
709
+
710
+ fields_by_name = PlanMyStuff::IssueField.list(org: repo.organization).index_by { |field| field.name.downcase }
711
+ inputs = updates.map { |name, value| build_issue_field_input(fields_by_name, name, value) }
712
+
713
+ PlanMyStuff.client.graphql(
714
+ PlanMyStuff::GraphQL::Queries::SET_ISSUE_FIELD_VALUES,
715
+ variables: { issueId: github_node_id, issueFields: inputs },
716
+ )
717
+
718
+ @issue_fields = nil
719
+ self
720
+ end
721
+
669
722
  private
670
723
 
671
724
  # Populates this instance from a GitHub API response.
@@ -695,6 +748,7 @@ module PlanMyStuff
695
748
  @body_dirty = false
696
749
  persisted!
697
750
  @comments = nil
751
+ @issue_fields = nil
698
752
  invalidate_links_cache!
699
753
  end
700
754
 
@@ -722,6 +776,7 @@ module PlanMyStuff
722
776
  self.metadata = other.metadata
723
777
  persisted!
724
778
  @comments = nil
779
+ @issue_fields = nil
725
780
  invalidate_links_cache!
726
781
  end
727
782
 
@@ -764,6 +819,7 @@ module PlanMyStuff
764
819
  }
765
820
  attrs[:body] = body if @body_dirty
766
821
  attrs[:assignees] = @pending_assignees unless @pending_assignees.nil?
822
+ attrs[:issue_fields] = @pending_issue_fields if @pending_issue_fields.present?
767
823
  attrs[:issue_type] = issue_type if issue_type_changed?
768
824
 
769
825
  clear_waiting_state_on_close(attrs)
@@ -789,6 +845,7 @@ module PlanMyStuff
789
845
  self.body = attrs[:body] if attrs.key?(:body)
790
846
  self.issue_type = attrs[:issue_type] if attrs.key?(:issue_type)
791
847
  @pending_assignees = attrs[:assignees] if attrs.key?(:assignees)
848
+ @pending_issue_fields = attrs[:issue_fields] if attrs.key?(:issue_fields)
792
849
  apply_metadata_attrs(attrs[:metadata]) if attrs.key?(:metadata)
793
850
  end
794
851
 
@@ -905,5 +962,47 @@ module PlanMyStuff
905
962
 
906
963
  id
907
964
  end
965
+
966
+ # @return [PlanMyStuff::IssueFieldValueSet]
967
+ def load_issue_fields!
968
+ return PlanMyStuff::IssueFieldValueSet.new({}) unless PlanMyStuff.configuration.issue_fields_enabled
969
+
970
+ data = PlanMyStuff.client.graphql(
971
+ PlanMyStuff::GraphQL::Queries::READ_ISSUE_FIELD_VALUES,
972
+ variables: { owner: repo.organization, name: repo.name, number: number },
973
+ )
974
+ nodes = data.dig(:repository, :issue, :issueFieldValues, :nodes)
975
+ PlanMyStuff::IssueFieldValueSet.from_graphql(nodes)
976
+ end
977
+
978
+ # Builds one element of the +issueFields+ argument to the +setIssueFieldValue+ mutation. Looks up the field
979
+ # definition in the provided hash to pick the right input fragment and (for single-select) resolve the option ID.
980
+ #
981
+ # @raise [PlanMyStuff::Error] if the field name is unknown on the org
982
+ #
983
+ # @param fields_by_name [Hash{String => PlanMyStuff::IssueField}] fields keyed by downcased display name
984
+ # @param name [String, Symbol]
985
+ # @param value [Object, nil]
986
+ #
987
+ # @return [Hash]
988
+ #
989
+ def build_issue_field_input(fields_by_name, name, value)
990
+ field = fields_by_name[name.to_s.downcase]
991
+ raise(PlanMyStuff::Error, "Unknown Issue Field #{name.inspect}") if field.nil?
992
+
993
+ return { fieldId: field.id, delete: true } if value.nil?
994
+
995
+ case field.type
996
+ when :single_select then { fieldId: field.id, singleSelectOptionId: field.option_id_for!(value) }
997
+ when :date then { fieldId: field.id, dateValue: value.to_date.iso8601 }
998
+ when :number
999
+ unless value.is_a?(Numeric)
1000
+ raise(PlanMyStuff::Error, "Issue Field #{name.inspect} expects Numeric, got #{value.inspect}")
1001
+ end
1002
+
1003
+ { fieldId: field.id, numberValue: value.to_f }
1004
+ when :text then { fieldId: field.id, textValue: value.to_s }
1005
+ end
1006
+ end
908
1007
  end
909
1008
  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
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 11
6
+ MINOR = 12
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
@@ -24,6 +24,8 @@ require_relative 'plan_my_stuff/engine' if defined?(Rails)
24
24
  require_relative 'plan_my_stuff/errors'
25
25
  require_relative 'plan_my_stuff/graphql/queries'
26
26
  require_relative 'plan_my_stuff/issue'
27
+ require_relative 'plan_my_stuff/issue_field'
28
+ require_relative 'plan_my_stuff/issue_field_value_set'
27
29
  require_relative 'plan_my_stuff/issue_metadata'
28
30
  require_relative 'plan_my_stuff/label'
29
31
  require_relative 'plan_my_stuff/link'
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.12.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