Skip to content

Commit b601290

Browse files
Automation more (#2705)
* Merge automated_fields and overridden_fields We need to merge the data we have, not replace it. Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> * Fix @automated_fields/@overridden_fields types We had been using lists for these types, but really that should be hashes. Fix the data type, which simplifies and clarifies quite a bit. Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> * Document why we only automate 1st edit Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> * Rename query params to overridden_fields_list and automated_fields_list Change external query string parameters from 'overridden' and 'automated' to 'overridden_fields_list' and 'automated_fields_list' to clarify that these are comma-separated lists of field names, not the full field data. This distinguishes them from the internal @overridden_fields and @automated_fields instance variables (which contain hashes with full metadata) and reduces risk of naming conflicts with future criteria names. Changes: - Update params[:overridden] to params[:overridden_fields_list] - Update params[:automated] to params[:automated_fields_list] - Update redirect URLs to use new parameter names - Add tests verifying automated_fields_list highlights fields with robot icon - Add tests verifying overridden_fields_list parameter works correctly Co-authored-by: David A. Wheeler <dwheeler@dwheeler.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> * Add merge_field_lists helper to preserve rich metadata Extract merge logic into a dedicated merge_field_lists method that explicitly handles merging URL parameter field lists (with empty {} values) with existing field data (which may have rich metadata like old_value, new_value, and explanation). The method ensures that: - Rich metadata from automation always takes priority - URL params can add new fields for highlighting - Empty existing_fields returns just the URL fields - Empty URL params returns existing fields unchanged This makes the merge intent clearer and prevents accidental loss of metadata when combining field lists from different sources. Changes: - Add merge_field_lists helper method with clear documentation - Replace inline merge with call to new helper - Add comprehensive tests for all merge scenarios: * Preserving rich metadata from existing fields * Adding new fields from URL params * Handling nil existing fields * Handling empty URL params Co-authored-by: David A. Wheeler <dwheeler@dwheeler.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> * Fix test to use new automated_fields_list parameter name Update test expectation from 'automated=' to 'automated_fields_list=' to match the renamed query parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> * Fix remaining tests for renamed query parameters Update test assertions and comments to use the new parameter names: - automated= -> automated_fields_list= - overridden= -> overridden_fields_list= This fixes the failing UsersManipulateProjectTest test and updates a comment in ProjectsControllerTest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> --------- Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 21c73b9 commit b601290

File tree

4 files changed

+317
-141
lines changed

4 files changed

+317
-141
lines changed

app/controllers/projects_controller.rb

Lines changed: 110 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,32 @@ class ProjectsController < ApplicationController
4949
fields_map.freeze
5050
end
5151

52+
# When editing a specific project there are two instance variables to track
53+
# automation highlights: @automated_fields and @overridden_fields.
54+
# These instance variables track which project fields were changed by
55+
# automation (Chief or query-string proposals) so the edit form can
56+
# highlight them for the user.
57+
#
58+
# Both are Hash{Symbol => Hash} keyed by field symbol
59+
# (e.g. :contribution_status):
60+
# - @automated_fields (yellow highlight): fields that were unknown/blank
61+
# (as old values) and then got filled in by automation.
62+
# Values: { new_value:, explanation: }
63+
# - @overridden_fields (orange highlight): fields that had a real user
64+
# value (not '?' or blank) and were forcibly changed by Chief.
65+
# Values: { old_value:, new_value:, explanation: }
66+
#
67+
# They are populated by categorize_automation_changes (first-edit),
68+
# apply_query_string_automation (URL proposals), run_save_automation
69+
# (save-time Chief), and parse_and_validate_field_list (redirect params).
70+
# Consumed by the edit view (via projects_helper automated_field_set /
71+
# overridden_field_set), format_override_details, and
72+
# build_automation_metadata (JSON API).
73+
#
74+
# The external query parameters automated_fields_list and
75+
# overridden_fields_list will set the keys, with the value {} if
76+
# there is nothing more specific, to highlight those fields.
77+
5278
# The 'badge', 'baseline_badge', and 'show_json' actions are special and
5379
# do NOT take a locale.
5480
skip_before_action :redir_missing_locale, only: %i[badge baseline_badge show_json]
@@ -566,6 +592,9 @@ def edit
566592
return if @criteria_level == 'permissions'
567593

568594
# Run first-edit automation if this level hasn't been edited yet
595+
# (`SECTION_saved` is false). Only do this on "first edit of this section"
596+
# because automation *can* take a while or the remote system could
597+
# crash; we don't want to slow down simple edits from users.
569598
run_first_edit_automation_if_needed
570599

571600
# Always consume query string proposals, even on revisits, so we
@@ -575,10 +604,13 @@ def edit
575604
# Results merge into @automated_fields for highlighting.
576605
apply_query_string_automation
577606

578-
# Restore override/automated highlighting from redirect query params
579-
# (handle_overridden_fields_redirect passes overridden= and automated=)
580-
@overridden_fields ||= parse_and_validate_field_list(params[:overridden])
581-
@automated_fields ||= parse_and_validate_field_list(params[:automated])
607+
# Merge override/automated highlighting from redirect query params.
608+
# (handle_overridden_fields_redirect passes overridden_fields_list= and automated_fields_list=)
609+
# Existing entries (from automation) take priority over URL params.
610+
@overridden_fields =
611+
merge_field_lists(params[:overridden_fields_list], @overridden_fields)
612+
@automated_fields =
613+
merge_field_lists(params[:automated_fields_list], @automated_fields)
582614

583615
return unless @project.notify_for_static_analysis?('0')
584616

@@ -1652,7 +1684,7 @@ def apply_query_string_automation
16521684
# Track which proposals actually changed values.
16531685
# Map _justification fields to their _status counterpart so the
16541686
# view (which highlights by status symbol) can find them.
1655-
@automated_fields ||= []
1687+
@automated_fields ||= {}
16561688
modified.each do |field_name|
16571689
new_value = @project.public_send(field_name)
16581690
next if original_values[field_name] == new_value
@@ -1663,8 +1695,8 @@ def apply_query_string_automation
16631695
else
16641696
field_name
16651697
end
1666-
@automated_fields << {
1667-
field: highlight_field, new_value: new_value, explanation: nil
1698+
@automated_fields[highlight_field] = {
1699+
new_value: new_value, explanation: nil
16681700
}
16691701
end
16701702
end
@@ -1733,10 +1765,10 @@ def capture_original_values
17331765
# - Non-criteria field filled from blank: automated
17341766
# Both lists are filtered to the current section.
17351767
# @param original_values [Hash] Field name => value before automation
1736-
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
1768+
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
17371769
def categorize_automation_changes(original_values)
1738-
automated = []
1739-
overridden = []
1770+
automated = {}
1771+
overridden = {}
17401772

17411773
original_values.each do |field_name, old_value|
17421774
new_value = @project.public_send(field_name)
@@ -1749,21 +1781,21 @@ def categorize_automation_changes(original_values)
17491781
)
17501782
elsif field_str.end_with?('_justification')
17511783
status_field = field_str.sub('_justification', '_status').to_sym
1752-
automated << {
1753-
field: status_field, new_value: new_value, explanation: new_value
1784+
automated[status_field] = {
1785+
new_value: new_value, explanation: new_value
17541786
}
17551787
elsif ALWAYS_AUTOMATABLE.include?(field_name) &&
17561788
old_value.blank? && new_value.present?
1757-
automated << {
1758-
field: field_name, new_value: new_value, explanation: nil
1789+
automated[field_name] = {
1790+
new_value: new_value, explanation: nil
17591791
}
17601792
end
17611793
end
17621794

17631795
@automated_fields = filter_to_current_section(automated)
17641796
@overridden_fields = filter_to_current_section(overridden)
17651797
end
1766-
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
1798+
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
17671799

17681800
# Categorize a status field change as automated or overridden.
17691801
# Unknown -> real value is automated (yellow); real -> real is
@@ -1778,14 +1810,14 @@ def categorize_status_automation(
17781810
if @project.respond_to?(justification_field)
17791811
@project.public_send(justification_field)
17801812
end
1781-
info = {
1782-
field: field_name, new_value: new_value, explanation: explanation
1783-
}
17841813
if old_value == CriterionStatus::UNKNOWN
1785-
automated << info
1814+
automated[field_name] = {
1815+
new_value: new_value, explanation: explanation
1816+
}
17861817
else
1787-
info[:old_value] = old_value
1788-
overridden << info
1818+
overridden[field_name] = {
1819+
old_value: old_value, new_value: new_value, explanation: explanation
1820+
}
17891821
end
17901822
end
17911823
# rubocop:enable Metrics/MethodLength
@@ -1836,34 +1868,46 @@ def parse_status_value(value)
18361868
end
18371869
end
18381870

1839-
# Parse comma-separated field list and validate field names
1840-
# Only accepts fields valid for the current section being edited
1871+
# Merge field list from URL params with existing field data.
1872+
# URL params provide field names (for highlighting), while existing_fields
1873+
# may have rich metadata (old_value, new_value, explanation).
1874+
# Rich metadata always takes priority over empty {} from URL params.
1875+
# If we're just highlighting we don't *need* the rich metadata
1876+
# (mere presence is enough), but we don't want to lose the rich metadata,
1877+
# in case later changes to our code use the rich metadata.
1878+
# @param field_list_param [String, nil] Comma-separated field names from URL
1879+
# @param existing_fields [Hash, nil] Existing field data with metadata
1880+
# @return [Hash{Symbol => Hash}] Merged fields preserving rich data
1881+
def merge_field_lists(field_list_param, existing_fields)
1882+
url_fields = parse_and_validate_field_list(field_list_param)
1883+
return existing_fields || {} if url_fields.empty?
1884+
return url_fields if existing_fields.blank?
1885+
1886+
# Merge: existing rich data takes priority over URL's empty {} values
1887+
url_fields.merge(existing_fields)
1888+
end
1889+
1890+
# Parse comma-separated field list and validate field names.
1891+
# Only accepts fields valid for the current section being edited.
18411892
# @param field_string [String, nil] Comma-separated field names
1842-
# @return [Array<Hash>] Array of validated field hashes with :field key
1893+
# @return [Hash{Symbol => Hash}] Validated fields keyed by field symbol
18431894
def parse_and_validate_field_list(field_string)
1844-
return [] if field_string.blank?
1895+
return {} if field_string.blank?
18451896

18461897
# Split by comma, strip whitespace, remove empty strings
18471898
field_names = field_string.split(',').map(&:strip).compact_blank
18481899

18491900
# Get valid fields for current section
18501901
valid_fields = fields_for_current_section
1851-
return [] if valid_fields.nil? # Invalid section level
1902+
return {} if valid_fields.nil? # Invalid section level
18521903

1853-
# Validate each field name
1854-
validated_fields = []
1904+
# Validate each field name; silently skip invalid ones
1905+
validated = {}
18551906
field_names.each do |field_name|
1856-
# Convert to symbol
18571907
field_sym = field_name.to_sym
1858-
1859-
# Only accept if field is in the valid set for this section
1860-
if valid_fields.include?(field_sym)
1861-
validated_fields << { field: field_sym }
1862-
end
1863-
# Silently skip invalid field names (don't raise error, just ignore)
1908+
validated[field_sym] = {} if valid_fields.include?(field_sym)
18641909
end
1865-
1866-
validated_fields
1910+
validated
18671911
end
18681912

18691913
# Categorize a single Chief proposal (override vs automated)
@@ -1881,17 +1925,15 @@ def categorize_chief_proposal(field, data, user_value, track_automated = true)
18811925
if data[:forced] && user_value.present? && user_value != CriterionStatus::UNKNOWN
18821926
# Forced Chief override of user's real value - ORANGE
18831927
# Always track overrides (needed for warning messages on any save)
1884-
@overridden_fields << {
1885-
field: field,
1928+
@overridden_fields[field] = {
18861929
old_value: user_value,
18871930
new_value: chief_value,
18881931
explanation: data[:explanation]
18891932
}
18901933
elsif (user_value == CriterionStatus::UNKNOWN || user_value.blank?) && track_automated
18911934
# Chief filling an unknown value - YELLOW
18921935
# Only track on save-and-continue so user reviews before accepting
1893-
@automated_fields << {
1894-
field: field,
1936+
@automated_fields[field] = {
18951937
new_value: chief_value,
18961938
explanation: data[:explanation]
18971939
}
@@ -1918,8 +1960,8 @@ def run_save_automation(changed_fields, user_set_values, chief_instance: nil, tr
19181960
# Categorize all proposals BEFORE applying changes.
19191961
# Forced overrides of real user values are always tracked (for warnings).
19201962
# Non-forced fills are tracked only on save-and-continue (for highlighting).
1921-
@overridden_fields = []
1922-
@automated_fields = []
1963+
@overridden_fields = {}
1964+
@automated_fields = {}
19231965

19241966
current_section_changes.each do |field, data|
19251967
categorize_chief_proposal(field, data, user_set_values[field], track_automated)
@@ -1944,22 +1986,16 @@ def run_save_automation(changed_fields, user_set_values, chief_instance: nil, tr
19441986
end
19451987
# rubocop:enable Metrics/MethodLength
19461988

1947-
# Filter a collection to only fields in the current section.
1948-
# Accepts a Hash (Chief proposals keyed by field name) or an Array
1949-
# of hashes with a :field key (automated/overridden field lists).
1950-
# @param collection [Hash, Array<Hash>] Items to filter
1951-
# @return [Hash, Array<Hash>] Filtered collection (same type as input)
1989+
# Filter a Hash to only include fields in the current section.
1990+
# @param collection [Hash] Items keyed by field symbol
1991+
# @return [Hash] Filtered to fields valid for the current section
19521992
def filter_to_current_section(collection)
19531993
valid_fields = fields_for_current_section
19541994

19551995
# If we don't have a valid field set, don't filter (safety fallback)
19561996
return collection if valid_fields.nil?
19571997

1958-
if collection.is_a?(Hash)
1959-
collection.slice(*valid_fields)
1960-
else
1961-
collection.select { |entry| valid_fields.include?(entry[:field]) }
1962-
end
1998+
collection.slice(*valid_fields)
19631999
end
19642000

19652001
# Handle Chief failures during save
@@ -2013,16 +2049,16 @@ def status_value_to_string(value)
20132049
def format_override_details
20142050
criteria_level_to_internal(@criteria_level)
20152051
details =
2016-
@overridden_fields.map do |r|
2052+
@overridden_fields.map do |field, data|
20172053
# Derive criterion key from status field: license_location_status -> license_location
2018-
criterion_key = r[:field].to_s.delete_suffix('_status').to_sym
2054+
criterion_key = field.to_s.delete_suffix('_status').to_sym
20192055
# Show user-visible criterion ID (e.g., license_location or OSPS-LE-03.01)
20202056
display_id = helpers.baseline_id_to_display(criterion_key)
20212057
t('projects.edit.automation.override_detail',
20222058
criterion: display_id,
2023-
old: status_value_to_string(r[:old_value]),
2024-
new: status_value_to_string(r[:new_value]),
2025-
explanation: r[:explanation])
2059+
old: status_value_to_string(data[:old_value]),
2060+
new: status_value_to_string(data[:new_value]),
2061+
explanation: data[:explanation])
20262062
end
20272063
details.join("\n")
20282064
end
@@ -2040,21 +2076,21 @@ def handle_overridden_fields_redirect(section)
20402076
# Log overrides for monitoring
20412077
Rails.logger.info(
20422078
"Chief override: project=#{@project.id} user=#{current_user&.id} " \
2043-
"fields=#{@overridden_fields.pluck(:field).join(',')}"
2079+
"fields=#{@overridden_fields.keys.join(',')}"
20442080
)
20452081

20462082
# Redirect with highlight parameters
2047-
overridden_field_names = @overridden_fields.map { |r| r[:field].to_s }
2048-
automated_field_names = @automated_fields.map { |r| r[:field].to_s }
2083+
overridden_field_names = @overridden_fields.keys.map(&:to_s)
2084+
automated_field_names = @automated_fields.keys.map(&:to_s)
20492085
first_overridden = overridden_field_names.first
20502086

20512087
redirect_to edit_project_section_path(
20522088
@project,
20532089
section,
20542090
locale: params[:locale],
20552091
anchor: first_overridden,
2056-
overridden: overridden_field_names.join(','),
2057-
automated: automated_field_names.join(',')
2092+
overridden_fields_list: overridden_field_names.join(','),
2093+
automated_fields_list: automated_field_names.join(',')
20582094
)
20592095
end
20602096
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -2078,8 +2114,8 @@ def perform_html_redirect_after_save(section)
20782114
flash[:info] = t('projects.edit.successfully_updated')
20792115
redirect_params = { locale: params[:locale] }
20802116
if @automated_fields&.any?
2081-
redirect_params[:automated] =
2082-
@automated_fields.map { |r| r[:field].to_s }.join(',')
2117+
redirect_params[:automated_fields_list] =
2118+
@automated_fields.keys.join(',')
20832119
end
20842120
redirect_to edit_project_section_path(@project, section,
20852121
**redirect_params) + url_anchor
@@ -2097,19 +2133,19 @@ def perform_html_redirect_after_save(section)
20972133
# rubocop:disable Metrics/MethodLength
20982134
def build_automation_metadata
20992135
{
2100-
overridden: @overridden_fields&.map do |r|
2136+
overridden: @overridden_fields&.map do |field, data|
21012137
{
2102-
field: r[:field],
2103-
old_value: r[:old_value],
2104-
new_value: r[:new_value],
2105-
explanation: r[:explanation]
2138+
field: field,
2139+
old_value: data[:old_value],
2140+
new_value: data[:new_value],
2141+
explanation: data[:explanation]
21062142
}
21072143
end || [],
2108-
automated: @automated_fields&.map do |r|
2144+
automated: @automated_fields&.map do |field, data|
21092145
{
2110-
field: r[:field],
2111-
value: r[:new_value],
2112-
explanation: r[:explanation]
2146+
field: field,
2147+
value: data[:new_value],
2148+
explanation: data[:explanation]
21132149
}
21142150
end || []
21152151
}

app/helpers/projects_helper.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@ def markdown(content)
2020
MarkdownProcessor.render(content)
2121
end
2222

23-
# Precomputed Set of automated field symbols for O(1) lookup.
24-
# Memoized per request (computed once, used by every status_chooser).
23+
# Hash of automated fields for O(1) lookup via Hash#include?.
24+
# Returns empty hash when @automated_fields is nil.
2525
def automated_field_set
26-
@automated_field_set ||= Set.new(@automated_fields&.pluck(:field))
26+
@automated_fields || {}
2727
end
2828

29-
# Precomputed Set of overridden field symbols for O(1) lookup.
30-
# Memoized per request (computed once, used by every status_chooser).
29+
# Hash of overridden fields for O(1) lookup via Hash#include?.
30+
# Returns empty hash when @overridden_fields is nil.
3131
def overridden_field_set
32-
@overridden_field_set ||= Set.new(@overridden_fields&.pluck(:field))
32+
@overridden_fields || {}
3333
end
3434

3535
# Convert a status integer value to its string representation.

0 commit comments

Comments
 (0)