plan_my_stuff 0.24.0 → 0.26.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: 1cf33c92cfa763f5ace87a1419cf8c7369f27e8adc10d1c092c044b99d151a4a
4
- data.tar.gz: 3b2a482534b9f9094d550385c064f50f4dc37db3c2a6361e4aa899d4b4dc5713
3
+ metadata.gz: 6561200ae8220464aa3e705f21ae4d6695a4c3ae3db222677bb1deddddcd37b7
4
+ data.tar.gz: 98d519079e5c203741de10ce667341246d20335253033357a5d3ae8e1b48feee
5
5
  SHA512:
6
- metadata.gz: ef36dbcfb77d4c3763d2165993c50fb9f4f7e8b0729a1efc1d63fb9155abb039bf9db92fb59a49d3302ebfec024bafc19dd15b54be84a2d7919040405a10803f
7
- data.tar.gz: beae8f4926a95e4a47786c4ffefc886300f255466d7e08a7e607f444e91883e920b3bf60d666d33b539eed2dac7a1241f64cd23ae42ebe292a8a113ce150de8d
6
+ metadata.gz: 8c78d58f9322827d0483389f56dd82d02786929b851cfc6bf81bdc11973f7cdb06e3866b12c40054d4db71f44a24f5c817fd41213b374cf96eeec75e4c384b44
7
+ data.tar.gz: 9b1464ab72de0568958a73d75bb79f4d7e4095bbebc9e97dcf5f97445c79eace14f83d65da99de36f61c10f5ca37e178e5ffb2ef59afa941cad8f49b3ba56be2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.0
4
+
5
+ ### Added
6
+
7
+ - `PlanMyStuff::Issue#awaiting_reply?` - returns `true` when the issue's `Issue Status` field is `Waiting on Reply`.
8
+ - `config.issue_field_names` (canonical field name => consumer field name) and `config.issue_field_values` (canonical
9
+ field name => `{ canonical value => consumer value }`) let a consuming org rename the native Issue Fields / option
10
+ labels the gem refers to internally. Translation runs via the new `PlanMyStuff::IssueFieldTranslation` module:
11
+ canonical => consumer on writes and filters (`set_issue_fields!`, `Issue.list` / `.count` `issue_fields:` +
12
+ `priority_list:`), consumer => canonical on reads (`Issue#issue_fields`), so internal comparisons like
13
+ `awaiting_reply?` / `priority_list?` keep working regardless of the org's naming. Unmapped names / values pass
14
+ through unchanged (identity fallback, mirroring `config.pipeline_statuses`).
15
+
16
+ ### Changed
17
+
18
+ - The waiting/inactivity lifecycle methods now write the native `Issue Status` issue field alongside their existing
19
+ label/metadata changes (gated on `config.issue_fields_enabled`): `enter_waiting_on_user!` sets `Waiting on Reply`,
20
+ `clear_waiting_on_user!` sets `Open`, and `reopen_by_reply!` sets `Reopened`. No-op when `issue_fields_enabled` is
21
+ `false`.
22
+
23
+ ## 0.25.0
24
+
25
+ ### Added
26
+
27
+ - `PlanMyStuff::Pipeline.clear_testing!(project_item, user: nil)` - reverses `request_testing!` by writing the
28
+ `:inactive` Testing field value. Fires `pipeline_testing_cleared.plan_my_stuff` with `field_name`, `value`,
29
+ `user` payload.
30
+ - `PlanMyStuff::Issues::TestingsController` + `POST/DELETE /issues/:issue_id/testing` route (gated on
31
+ `config.pipeline_enabled`). Backs new "Request testing" / "Clear testing" buttons on the issue show view
32
+ (closes #52).
33
+
3
34
  ## 0.24.0
4
35
 
5
36
  ### Added
data/CONFIGURATION.md CHANGED
@@ -214,6 +214,8 @@ config.issue_types = {
214
214
  | Option | Type | Default | Description |
215
215
  |---|---|---|---|
216
216
  | `issue_fields_enabled` | `Boolean` | `true` | Whether the Issue Fields public preview is wired up for the org. |
217
+ | `issue_field_names` | `Hash{String => String}` | `{}` | Canonical Issue Field name => the consumer org's field name. |
218
+ | `issue_field_values` | `Hash` | `{}` | Per field: canonical value => consumer value (single-select labels). |
217
219
 
218
220
  GitHub Issue Fields are structured per-issue metadata (text, number, date, or single-select)
219
221
  configured once at the org level. The preview is rolling out org-by-org. Leave this flag at its
@@ -229,6 +231,27 @@ With the flag off:
229
231
  config.issue_fields_enabled = false # org not admitted to the preview yet
230
232
  ```
231
233
 
234
+ `issue_field_names` and `issue_field_values` let a consuming org rename the native Issue Fields (and their
235
+ single-select option labels) the gem refers to internally, the same way `pipeline_statuses` aliases pipeline
236
+ statuses. Translation is bidirectional via `PlanMyStuff::IssueFieldTranslation`: canonical => consumer on writes and
237
+ filters, consumer => canonical on reads, so internal comparisons (`awaiting_reply?`, `priority_list?`) keep working.
238
+ Unmapped names / values pass through unchanged.
239
+
240
+ ```ruby
241
+ config.issue_field_names = {
242
+ 'Issue Status' => 'Status',
243
+ 'Priority' => 'Prio',
244
+ }
245
+ config.issue_field_values = {
246
+ 'Issue Status' => {
247
+ 'Submitted' => 'Triaged',
248
+ 'Waiting on Reply' => 'Awaiting Customer',
249
+ 'Open' => 'Open',
250
+ 'Reopened' => 'Reopened',
251
+ },
252
+ }
253
+ ```
254
+
232
255
  ## Release pipeline
233
256
 
234
257
  | Option | Type | Default | Description |
@@ -392,6 +415,7 @@ Controllable keys (with gem defaults):
392
415
  | `:'issues/closures'` | `plan_my_stuff/issues/closures` |
393
416
  | `:'issues/viewers'` | `plan_my_stuff/issues/viewers` |
394
417
  | `:'issues/takes'` | `plan_my_stuff/issues/takes` |
418
+ | `:'issues/testings'` | `plan_my_stuff/issues/testings` |
395
419
  | `:'issues/waitings'` | `plan_my_stuff/issues/waitings` |
396
420
  | `:'issues/links'` | `plan_my_stuff/issues/links` |
397
421
  | `:'issues/approvals'` | `plan_my_stuff/issues/approvals` |
@@ -12,7 +12,11 @@ module PlanMyStuff
12
12
  # POST /issues/:issue_id/closure
13
13
  def create
14
14
  issue = PlanMyStuff::Issue.find(params[:issue_id])
15
- issue.update!(state: :closed)
15
+ to_update = {}
16
+ if PlanMyStuff.configuration.issue_fields_enabled
17
+ to_update[:issue_fields] = { 'Issue Status' => 'Fixed' }
18
+ end
19
+ issue.update!(state: :closed, **to_update)
16
20
 
17
21
  yield(issue) if block_given?
18
22
  return if performed?
@@ -28,7 +32,11 @@ module PlanMyStuff
28
32
  # DELETE /issues/:issue_id/closure
29
33
  def destroy
30
34
  issue = PlanMyStuff::Issue.find(params[:issue_id])
31
- issue.update!(state: :open)
35
+ to_update = {}
36
+ if PlanMyStuff.configuration.issue_fields_enabled
37
+ to_update[:issue_fields] = { 'Issue Status' => 'Reopened' }
38
+ end
39
+ issue.update!(state: :open, **to_update)
32
40
 
33
41
  yield(issue) if block_given?
34
42
  return if performed?
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanMyStuff
4
+ module Issues
5
+ # Toggles the pipeline +Testing+ custom field on an issue's project item via
6
+ # +PlanMyStuff::Pipeline.request_testing!+ and +PlanMyStuff::Pipeline.clear_testing!+. Backs the "Request testing" /
7
+ # "Clear testing" buttons on the mounted issue show view.
8
+ #
9
+ # POST /issues/:issue_id/testing -> create (flips +Testing+ to its active value)
10
+ # DELETE /issues/:issue_id/testing -> destroy (flips +Testing+ back to inactive)
11
+ #
12
+ class TestingsController < PlanMyStuff::ApplicationController
13
+ before_action :require_support_user
14
+
15
+ # POST /issues/:issue_id/testing
16
+ def create
17
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
18
+ project_item = find_project_item!(issue)
19
+
20
+ PlanMyStuff::Pipeline.request_testing!(project_item, user: pms_current_user)
21
+
22
+ yield(project_item) if block_given?
23
+ return if performed?
24
+
25
+ flash[:success] = "Issue ##{issue.number} marked as in testing."
26
+ redirect_to(plan_my_stuff.issue_path(issue))
27
+ rescue ArgumentError, PlanMyStuff::Error => e
28
+ pms_handle_rescue(e)
29
+ flash[:error] = e.message
30
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
31
+ end
32
+
33
+ # DELETE /issues/:issue_id/testing
34
+ def destroy
35
+ issue = PlanMyStuff::Issue.find(params[:issue_id])
36
+ project_item = find_project_item!(issue)
37
+
38
+ PlanMyStuff::Pipeline.clear_testing!(project_item, user: pms_current_user)
39
+
40
+ yield(project_item) if block_given?
41
+ return if performed?
42
+
43
+ flash[:success] = "Issue ##{issue.number} testing cleared."
44
+ redirect_to(plan_my_stuff.issue_path(issue))
45
+ rescue ArgumentError, PlanMyStuff::Error => e
46
+ pms_handle_rescue(e)
47
+ flash[:error] = e.message
48
+ redirect_to(plan_my_stuff.issue_path(params[:issue_id]))
49
+ end
50
+
51
+ private
52
+
53
+ # Redirects non-support users back to the issue page. Mirrors +Issues::TakesController+'s authorization check.
54
+ #
55
+ # @return [void]
56
+ #
57
+ def require_support_user
58
+ return if support_user?
59
+
60
+ redirect_to_unauthorized(
61
+ plan_my_stuff.issue_path(params[:issue_id]),
62
+ )
63
+ end
64
+
65
+ # Looks up the pipeline project item for the issue, raising if missing. Defensive against a stale view (button
66
+ # rendered before the item was removed) or a race with another tab removing the issue from the pipeline.
67
+ #
68
+ # @raise [PlanMyStuff::Error] if the issue is not in the pipeline
69
+ #
70
+ # @param issue [PlanMyStuff::Issue]
71
+ #
72
+ # @return [PlanMyStuff::ProjectItem]
73
+ #
74
+ def find_project_item!(issue)
75
+ project_item = PlanMyStuff::Pipeline::IssueLinker.find_project_item(issue.number)
76
+ return project_item if project_item.present?
77
+
78
+ raise(PlanMyStuff::Error, "Issue ##{issue.number} is not in the pipeline.")
79
+ end
80
+ end
81
+ end
82
+ end
@@ -20,9 +20,24 @@
20
20
  <% if @support_user && @pipeline_enabled && @pipeline_item.nil? && @issue.assignees.blank? %>
21
21
  <%= button_to('Take', plan_my_stuff.issue_take_path(@issue), method: :post) %>
22
22
  <% end %>
23
- <% if @support_user && @pipeline_enabled && @current_user_login.present? && @issue.assignees.include?(@current_user_login) %>
23
+ <%
24
+ show_unassign =
25
+ @support_user &&
26
+ @pipeline_enabled &&
27
+ @current_user_login.present? &&
28
+ @issue.assignees.include?(@current_user_login)
29
+ %>
30
+ <% if show_unassign %>
24
31
  <%= button_to('Unassign', plan_my_stuff.issue_take_path(@issue), method: :delete) %>
25
32
  <% end %>
33
+ <% if @support_user && @pipeline_enabled && @pipeline_item.present? %>
34
+ <% testing_value = @pipeline_item.field_values[PlanMyStuff.configuration.pipeline_testing_field_name] %>
35
+ <% if testing_value == PlanMyStuff.configuration.pipeline_testing_values[:active] %>
36
+ <%= button_to('Clear testing', plan_my_stuff.issue_testing_path(@issue), method: :delete) %>
37
+ <% else %>
38
+ <%= button_to('Request testing', plan_my_stuff.issue_testing_path(@issue), method: :post) %>
39
+ <% end %>
40
+ <% end %>
26
41
  <% if @support_user %>
27
42
  <%= link_to('Start Testing Project', plan_my_stuff.new_testing_project_path(subject_url: @issue.html_url)) %>
28
43
  <% end %>
data/config/routes.rb CHANGED
@@ -15,6 +15,11 @@ PlanMyStuff::Engine.routes.draw do
15
15
  only: %i[create destroy],
16
16
  controller: config.controller_for(:'issues/takes'),
17
17
  )
18
+ resource(
19
+ :testing,
20
+ only: %i[create destroy],
21
+ controller: config.controller_for(:'issues/testings'),
22
+ )
18
23
  end
19
24
  resources :comments, only: %i[create edit update], controller: config.controller_for(:comments)
20
25
  resources :labels, only: %i[create destroy], controller: config.controller_for(:labels)
@@ -193,6 +193,27 @@ PlanMyStuff.configure do |config|
193
193
  #
194
194
  # config.issue_fields_enabled = false
195
195
 
196
+ # Rename the native Issue Fields the gem refers to internally to whatever your
197
+ # org actually calls them. Keys are the gem's canonical names; values are your
198
+ # org's field names. Applied on reads, writes, and filters; unmapped names pass
199
+ # through unchanged.
200
+ # config.issue_field_names = {
201
+ # 'Issue Status' => 'Status',
202
+ # 'Priority' => 'Prio',
203
+ # }
204
+
205
+ # Rename single-select option labels, nested by canonical field name. Keys are
206
+ # the gem's canonical values; values are your org's option labels. Unmapped
207
+ # fields / values pass through unchanged.
208
+ # config.issue_field_values = {
209
+ # 'Issue Status' => {
210
+ # 'Submitted' => 'Triaged',
211
+ # 'Waiting on Reply' => 'Awaiting Customer',
212
+ # 'Open' => 'Open',
213
+ # 'Reopened' => 'Reopened',
214
+ # },
215
+ # }
216
+
196
217
  # --------------------------------------------------------------------------
197
218
  # Boot behavior
198
219
  # --------------------------------------------------------------------------
@@ -348,6 +369,7 @@ PlanMyStuff.configure do |config|
348
369
  # :'issues/closures' => 'plan_my_stuff/issues/closures'
349
370
  # :'issues/viewers' => 'plan_my_stuff/issues/viewers'
350
371
  # :'issues/takes' => 'plan_my_stuff/issues/takes'
372
+ # :'issues/testings' => 'plan_my_stuff/issues/testings'
351
373
  # :'issues/waitings' => 'plan_my_stuff/issues/waitings'
352
374
  # :'issues/links' => 'plan_my_stuff/issues/links'
353
375
  # :'issues/approvals' => 'plan_my_stuff/issues/approvals'
@@ -18,6 +18,7 @@ module PlanMyStuff
18
18
  'issues/closures': 'plan_my_stuff/issues/closures',
19
19
  'issues/viewers': 'plan_my_stuff/issues/viewers',
20
20
  'issues/takes': 'plan_my_stuff/issues/takes',
21
+ 'issues/testings': 'plan_my_stuff/issues/testings',
21
22
  'issues/waitings': 'plan_my_stuff/issues/waitings',
22
23
  'issues/links': 'plan_my_stuff/issues/links',
23
24
  'issues/approvals': 'plan_my_stuff/issues/approvals',
@@ -196,6 +197,23 @@ module PlanMyStuff
196
197
  #
197
198
  attr_accessor :pipeline_testing_values
198
199
 
200
+ # Canonical Issue Field name to consumer field name map. Lets a consuming org rename the native issue fields the
201
+ # gem refers to internally (e.g. +"Issue Status"+ -> +"Status"+) without touching gem code. Applied via
202
+ # +PlanMyStuff::IssueFieldTranslation+ on reads, writes, and filters; unmapped names pass through unchanged.
203
+ #
204
+ # @return [Hash{String => String}]
205
+ #
206
+ attr_accessor :issue_field_names
207
+
208
+ # Canonical Issue Field value translations, nested by canonical field name:
209
+ # +{ 'Issue Status' => { 'Waiting on Reply' => 'Awaiting Customer', ... } }+. Lets a consuming org rename
210
+ # single-select option labels without touching gem code. Applied via +PlanMyStuff::IssueFieldTranslation+;
211
+ # unmapped fields / values pass through unchanged.
212
+ #
213
+ # @return [Hash{String => Hash{String => String}}]
214
+ #
215
+ attr_accessor :issue_field_values
216
+
199
217
  # @return [String] branch name that PRs merge into for "Ready for release" transition
200
218
  attr_accessor :main_branch
201
219
 
@@ -403,6 +421,8 @@ module PlanMyStuff
403
421
  @pipeline_statuses = {}
404
422
  @pipeline_testing_field_name = PlanMyStuff::Pipeline::Testing::FIELD_NAME
405
423
  @pipeline_testing_values = PlanMyStuff::Pipeline::Testing::VALUES.dup
424
+ @issue_field_names = {}
425
+ @issue_field_values = {}
406
426
  @main_branch = 'main'
407
427
  @production_branch = 'production'
408
428
  @mount_groups = { webhooks: true, issues: true, projects: true }
@@ -108,6 +108,11 @@ module PlanMyStuff
108
108
  raise(PlanMyStuff::IssueFieldsNotEnabledError)
109
109
  end
110
110
 
111
+ if issue_fields.present?
112
+ issue_fields = issue_fields.to_h.transform_keys(&:to_s)
113
+ issue_fields['Issue Status'] = 'Submitted' if issue_fields['Issue Status'].blank?
114
+ end
115
+
111
116
  if body.blank?
112
117
  raise(PlanMyStuff::ValidationError.new('body must be present', field: :body, expected_type: :string))
113
118
  end
@@ -356,7 +361,7 @@ module PlanMyStuff
356
361
  field_pairs = []
357
362
  field_pairs.concat(build_issue_field_filter_pairs(issue_fields)) if issue_fields.present?
358
363
  if priority_list && PlanMyStuff.configuration.issue_fields_enabled
359
- field_pairs << 'priority-list:Yes'
364
+ field_pairs.concat(build_issue_field_filter_pairs('Priority List' => 'Yes'))
360
365
  end
361
366
  params[:issue_field_values] = field_pairs.join(',') if field_pairs.present?
362
367
 
@@ -428,7 +433,7 @@ module PlanMyStuff
428
433
  field_pairs = []
429
434
  field_pairs.concat(build_issue_field_filter_pairs(issue_fields)) if issue_fields.present?
430
435
  if priority_list && PlanMyStuff.configuration.issue_fields_enabled
431
- field_pairs << 'priority-list:Yes'
436
+ field_pairs.concat(build_issue_field_filter_pairs('Priority List' => 'Yes'))
432
437
  end
433
438
  qualifiers += field_pairs.map { |pair| "field.#{pair}" }
434
439
 
@@ -644,11 +649,13 @@ module PlanMyStuff
644
649
  #
645
650
  def build_issue_field_filter_pairs(hash)
646
651
  hash.flat_map do |name, value|
647
- slug = field_filter_slug(name)
652
+ slug = field_filter_slug(PlanMyStuff::IssueFieldTranslation.consumer_field_name(name))
648
653
  case value
649
654
  when nil then []
650
655
  when Range then range_field_filter_pairs(slug, value)
651
- else ["#{slug}:#{format_field_filter_value(value)}"]
656
+ else
657
+ consumer_value = PlanMyStuff::IssueFieldTranslation.consumer_value(name, value)
658
+ ["#{slug}:#{format_field_filter_value(consumer_value)}"]
652
659
  end
653
660
  end
654
661
  end
@@ -1028,7 +1035,13 @@ module PlanMyStuff
1028
1035
  raise(PlanMyStuff::IssueFieldsNotEnabledError) unless PlanMyStuff.configuration.issue_fields_enabled
1029
1036
 
1030
1037
  fields_by_name = PlanMyStuff::IssueField.list(org: repo.organization).index_by { |field| field.name.downcase }
1031
- inputs = updates.map { |name, value| build_issue_field_input(fields_by_name, name, value) }
1038
+ inputs = updates.map do |name, value|
1039
+ build_issue_field_input(
1040
+ fields_by_name,
1041
+ PlanMyStuff::IssueFieldTranslation.consumer_field_name(name),
1042
+ PlanMyStuff::IssueFieldTranslation.consumer_value(name, value),
1043
+ )
1044
+ end
1032
1045
 
1033
1046
  PlanMyStuff.client.graphql(
1034
1047
  PlanMyStuff::GraphQL::Queries::SET_ISSUE_FIELD_VALUES,
@@ -3,6 +3,11 @@
3
3
  module PlanMyStuff
4
4
  module IssueExtractions
5
5
  module Waiting
6
+ # @return [Boolean]
7
+ def awaiting_reply?
8
+ issue_fields['Issue Status'] == 'Waiting on Reply'
9
+ end
10
+
6
11
  # Marks the issue as waiting on an end-user reply. Sets +metadata.waiting_on_user_at+ to now, (re)computes
7
12
  # +metadata.next_reminder_at+, and adds the configured +waiting_on_user_label+ to the issue. Called from
8
13
  # +Comment.create!+ when a support user posts a comment with +waiting_on_reply: true+, and from the
@@ -19,6 +24,11 @@ module PlanMyStuff
19
24
  PlanMyStuff::Label.ensure!(repo: repo, name: label)
20
25
  PlanMyStuff::Label.add!(issue: self, labels: [label], user: user) if labels.exclude?(label)
21
26
 
27
+ to_update = {}
28
+ if PlanMyStuff.configuration.issue_fields_enabled
29
+ to_update[:issue_fields] = { 'Issue Status' => 'Waiting on Reply' }
30
+ end
31
+
22
32
  self.class.update!(
23
33
  number: number,
24
34
  repo: repo,
@@ -26,6 +36,7 @@ module PlanMyStuff
26
36
  waiting_on_user_at: PlanMyStuff.format_time(now),
27
37
  next_reminder_at: format_next_reminder_at(from: now),
28
38
  },
39
+ **to_update,
29
40
  )
30
41
  reload
31
42
  end
@@ -42,6 +53,11 @@ module PlanMyStuff
42
53
 
43
54
  PlanMyStuff::Label.remove!(issue: self, labels: [label]) if labels.include?(label)
44
55
 
56
+ to_update = {}
57
+ if PlanMyStuff.configuration.issue_fields_enabled
58
+ to_update[:issue_fields] = { 'Issue Status' => 'Open' }
59
+ end
60
+
45
61
  self.class.update!(
46
62
  number: number,
47
63
  repo: repo,
@@ -50,6 +66,7 @@ module PlanMyStuff
50
66
  next_reminder_at:
51
67
  metadata.waiting_on_approval_at ? PlanMyStuff.format_time(metadata.next_reminder_at) : nil,
52
68
  },
69
+ **to_update,
53
70
  )
54
71
  reload
55
72
  end
@@ -67,11 +84,17 @@ module PlanMyStuff
67
84
  inactive_label = PlanMyStuff.configuration.user_inactive_label
68
85
  PlanMyStuff::Label.remove!(issue: self, labels: [inactive_label]) if labels.include?(inactive_label)
69
86
 
87
+ to_update = {}
88
+ if PlanMyStuff.configuration.issue_fields_enabled
89
+ to_update[:issue_fields] = { 'Issue Status' => 'Reopened' }
90
+ end
91
+
70
92
  self.class.update!(
71
93
  number: number,
72
94
  repo: repo,
73
95
  state: :open,
74
96
  metadata: { closed_by_inactivity: false },
97
+ **to_update,
75
98
  )
76
99
  reload
77
100
 
@@ -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
@@ -6,7 +6,8 @@ module PlanMyStuff
6
6
  # as +Date+, number fields as +Float+, single-select fields as the option name +String+, and
7
7
  # text fields as the raw +String+.
8
8
  #
9
- # Access is by field display name; string and symbol keys both work. Iteration yields
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
10
11
  # +[name, value]+ pairs in the order GitHub returned them.
11
12
  class IssueFieldValueSet
12
13
  include Enumerable
@@ -19,7 +20,10 @@ module PlanMyStuff
19
20
  # @return [PlanMyStuff::IssueFieldValueSet]
20
21
  #
21
22
  def from_graphql(nodes)
22
- pairs = Array.wrap(nodes).map { |node| [node.dig(:field, :name), coerce(node)] }
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
23
27
  new(pairs.to_h)
24
28
  end
25
29
 
@@ -200,6 +200,25 @@ module PlanMyStuff
200
200
  result
201
201
  end
202
202
 
203
+ # Reverses +request_testing!+ by flipping the +Testing+ custom single-select field back to its inactive value.
204
+ # Not approval-gated -- the forward transition (+request_testing!+) was already gated, so clearing should always
205
+ # succeed (matches +remove!+).
206
+ #
207
+ # @param project_item [PlanMyStuff::ProjectItem]
208
+ # @param user [Object, nil] actor forwarded to the +pipeline_testing_cleared.plan_my_stuff+ payload
209
+ #
210
+ # @return [Hash] mutation result
211
+ #
212
+ def clear_testing!(project_item, user: nil)
213
+ field_name = PlanMyStuff.configuration.pipeline_testing_field_name
214
+ value = PlanMyStuff.configuration.pipeline_testing_values.fetch(:inactive)
215
+ result = project_item.update_single_select_field!(field_name, value)
216
+
217
+ instrument('testing_cleared', project_item, field_name: field_name, value: value, user: user)
218
+
219
+ result
220
+ end
221
+
203
222
  # Moves a project item to "Ready for Release".
204
223
  #
205
224
  # @param project_item [PlanMyStuff::ProjectItem]
@@ -3,7 +3,7 @@
3
3
  module PlanMyStuff
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 24
6
+ MINOR = 26
7
7
  TINY = 0
8
8
 
9
9
  # Set PRE to nil unless it's a pre-release (beta, rc, etc.)
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.24.0
4
+ version: 0.26.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-05-21 00:00:00.000000000 Z
11
+ date: 2026-05-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -61,6 +61,7 @@ files:
61
61
  - app/controllers/plan_my_stuff/issues/closures_controller.rb
62
62
  - app/controllers/plan_my_stuff/issues/links_controller.rb
63
63
  - app/controllers/plan_my_stuff/issues/takes_controller.rb
64
+ - app/controllers/plan_my_stuff/issues/testings_controller.rb
64
65
  - app/controllers/plan_my_stuff/issues/viewers_controller.rb
65
66
  - app/controllers/plan_my_stuff/issues/waitings_controller.rb
66
67
  - app/controllers/plan_my_stuff/issues_controller.rb
@@ -133,6 +134,7 @@ files:
133
134
  - lib/plan_my_stuff/issue_extractions/viewers.rb
134
135
  - lib/plan_my_stuff/issue_extractions/waiting.rb
135
136
  - lib/plan_my_stuff/issue_field.rb
137
+ - lib/plan_my_stuff/issue_field_translation.rb
136
138
  - lib/plan_my_stuff/issue_field_value_set.rb
137
139
  - lib/plan_my_stuff/issue_metadata.rb
138
140
  - lib/plan_my_stuff/label.rb