Skip to content

Commit 6aedcab

Browse files
committed
Improve PreviewQuery error detection and update MCP descriptions
Expand PreviewQuery to detect additional alphanumeric identifier format violations: case-mismatch, underscores, leading digits, and purely numerical identifiers. Restructure error classification with FORMAT_RULES for clarity and add corresponding locale strings. Update MCP tool descriptions for search_projects, search_programs, and search_portfolios to reflect case-insensitive identifier matching, with updated test expectations.
1 parent 2b3fc7e commit 6aedcab

File tree

9 files changed

+109
-42
lines changed

9 files changed

+109
-42
lines changed

app/services/mcp_tools/search_portfolios.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class SearchPortfolios < Base
4848
type: :object,
4949
properties: {
5050
name: { type: "string", description: "Name of the portfolio. Accepts partial names, not case-sensitive." },
51-
identifier: { type: "string", description: "Portfolio identifier. Case-sensitive, matching exactly." },
51+
identifier: { type: "string", description: "Portfolio identifier. Case-insensitive, matching exactly." },
5252
status_code: { type: "string", enum: Project.status_codes.keys, description: "The portfolio status." }
5353
}
5454
)

app/services/mcp_tools/search_programs.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class SearchPrograms < Base
4848
type: :object,
4949
properties: {
5050
name: { type: "string", description: "Name of the program. Accepts partial names, not case-sensitive." },
51-
identifier: { type: "string", description: "Program identifier. Case-sensitive, matching exactly." },
51+
identifier: { type: "string", description: "Program identifier. Case-insensitive, matching exactly." },
5252
status_code: { type: "string", enum: Project.status_codes.keys, description: "The program status." }
5353
}
5454
)

app/services/mcp_tools/search_projects.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class SearchProjects < Base
4848
type: :object,
4949
properties: {
5050
name: { type: "string", description: "Name of the project. Accepts partial project names, not case-sensitive." },
51-
identifier: { type: "string", description: "Project identifier. Case-sensitive, matching exactly." },
51+
identifier: { type: "string", description: "Project identifier. Case-insensitive, matching exactly." },
5252
status_code: { type: "string", enum: Project.status_codes.keys, description: "The project status." }
5353
}
5454
)

app/services/work_packages/identifier_autofix/preview_query.rb

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,42 +34,71 @@ class PreviewQuery
3434
Result = Data.define(:projects_data, :total_count)
3535
DISPLAY_COUNT = 5
3636

37+
# Priority-ordered format rules for identifier classification.
38+
FORMAT_RULES = [
39+
[:too_long, ->(id, max) { id.length > max }],
40+
[:numerical, ->(id, _) { id.match?(/\A\d+\z/) }],
41+
[:starts_with_number, ->(id, _) { id.match?(/\A\d/) }],
42+
[:special_characters, ->(id, _) { id.match?(/[^a-zA-Z0-9]/) }],
43+
[:not_uppercase, ->(id, _) { id != id.upcase }]
44+
].freeze
45+
3746
def call
38-
total = problematic_scope.count
39-
preview = problematic_scope
40-
.select(:id, :name, :identifier)
41-
.limit(DISPLAY_COUNT)
42-
.to_a
43-
44-
suggestions = WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.call(
45-
preview,
46-
in_use_identifiers:,
47-
reserved_identifiers:
48-
)
47+
Result.new(projects_data: build_projects_data, total_count: problematic_scope.count)
48+
end
4949

50-
projects_data = suggestions.map do |entry|
50+
private
51+
52+
def build_projects_data
53+
generate_suggestions.map do |entry|
5154
entry.merge(error_reason: error_reason(entry[:current_identifier]))
5255
end
56+
end
5357

54-
Result.new(projects_data:, total_count: total)
58+
def generate_suggestions
59+
ProjectIdentifierSuggestionGenerator.call(
60+
preview_projects,
61+
in_use_identifiers:,
62+
reserved_identifiers:
63+
)
5564
end
5665

57-
private
66+
def preview_projects
67+
problematic_scope
68+
.select(:id, :name, :identifier)
69+
.limit(DISPLAY_COUNT)
70+
.to_a
71+
end
5872

73+
# Scope conditions must cover all identifiers classifiable by #error_reason.
5974
def problematic_scope
60-
@problematic_scope ||= Project.where(
61-
"length(identifier) > ? OR identifier ~ ?",
62-
ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max],
63-
"[^a-zA-Z0-9_]"
64-
)
75+
@problematic_scope ||= exceeds_max_length
76+
.or(contains_non_alphanumeric)
77+
.or(starts_with_digit)
78+
.or(not_fully_uppercased)
6579
end
6680

81+
def exceeds_max_length = Project.where("length(identifier) > ?", max_identifier_length)
82+
def contains_non_alphanumeric = Project.where("identifier ~ ?", "[^a-zA-Z0-9]")
83+
def starts_with_digit = Project.where("identifier ~ ?", "^[0-9]")
84+
def not_fully_uppercased = Project.where("identifier != UPPER(identifier)")
85+
86+
def max_identifier_length = ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max]
87+
88+
# Must handle all identifiers matched by #problematic_scope.
6789
def error_reason(identifier)
68-
if identifier.length > ProjectIdentifierSuggestionGenerator::IDENTIFIER_LENGTH[:max]
69-
:too_long
70-
elsif identifier.match?(/[^a-zA-Z0-9_]/)
71-
:special_characters
72-
elsif in_use_identifiers.include?(identifier)
90+
format_error_reason(identifier) || collision_error_reason(identifier) || :unknown
91+
end
92+
93+
def format_error_reason(identifier)
94+
FORMAT_RULES.each do |reason, check|
95+
return reason if check.call(identifier, max_identifier_length)
96+
end
97+
nil
98+
end
99+
100+
def collision_error_reason(identifier)
101+
if in_use_identifiers.include?(identifier)
73102
:in_use
74103
elsif reserved_identifiers.include?(identifier)
75104
:reserved

config/locales/en.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,13 @@ en:
411411
label_example_work_package_id: Example work package ID
412412
autofix_preview:
413413
error_too_long: Has to be fewer than 5 characters
414+
error_numerical: Cannot be purely numerical
415+
error_starts_with_number: Cannot start with a number
414416
error_special_characters: Special characters not allowed
417+
error_not_uppercase: Must be uppercase
415418
error_in_use: Already in use as another project's active handle
416419
error_reserved: Reserved by another project's handle history
420+
error_unknown: Needs manual review
417421
remaining_projects:
418422
one: "... 1 more project"
419423
other: "... %{count} more projects"

spec/requests/mcp/mcp_tools/search_portfolios_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,12 @@
9090
end
9191
end
9292

93-
context "when passing a non-exact identifier" do
93+
context "when passing a case-variant identifier" do
9494
let(:call_args) { { identifier: "Abc" } }
9595

96-
it "does not find the portfolio" do
96+
it "finds the portfolio (case-insensitive)" do
9797
subject
98-
expect(parsed_results.dig("structuredContent", "items")).to be_empty
98+
expect(parsed_results.dig("structuredContent", "items")).to be_present
9999
end
100100
end
101101

spec/requests/mcp/mcp_tools/search_programs_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,12 @@
9090
end
9191
end
9292

93-
context "when passing a non-exact identifier" do
93+
context "when passing a case-variant identifier" do
9494
let(:call_args) { { identifier: "Abc" } }
9595

96-
it "does not find the program" do
96+
it "finds the program (case-insensitive)" do
9797
subject
98-
expect(parsed_results.dig("structuredContent", "items")).to be_empty
98+
expect(parsed_results.dig("structuredContent", "items")).to be_present
9999
end
100100
end
101101

spec/requests/mcp/mcp_tools/search_projects_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,12 @@
8989
end
9090
end
9191

92-
context "when passing a non-exact identifier" do
92+
context "when passing a case-variant identifier" do
9393
let(:call_args) { { identifier: "Abc" } }
9494

95-
it "does not find the project" do
95+
it "finds the project (case-insensitive)" do
9696
subject
97-
expect(parsed_results.dig("structuredContent", "items")).to be_empty
97+
expect(parsed_results.dig("structuredContent", "items")).to be_present
9898
end
9999
end
100100

spec/services/work_packages/identifier_autofix/preview_query_spec.rb

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,22 @@
3535

3636
let(:display_count) { described_class::DISPLAY_COUNT }
3737

38+
# Store identifiers bypassing normalizes (which would downcase/upcase them)
39+
def set_raw_identifier(project, identifier)
40+
Project.where(id: project.id).update_all(Arel.sql("identifier = #{Project.connection.quote(identifier)}"))
41+
project
42+
end
43+
3844
def create_problematic_project(name:, identifier:)
39-
create(:project, name:, identifier:)
45+
set_raw_identifier(create(:project, name:), identifier)
4046
end
4147

4248
def create_valid_project(name:, identifier:)
43-
create(:project, name:, identifier:)
49+
set_raw_identifier(create(:project, name:), identifier)
4450
end
4551

4652
context "when there are no problematic projects" do
47-
before { create_valid_project(name: "Clean Project", identifier: "clean") }
53+
before { create_valid_project(name: "Clean Project", identifier: "CLEAN") }
4854

4955
it "returns total_count 0 and empty projects_data" do
5056
expect(result.total_count).to eq(0)
@@ -55,9 +61,9 @@ def create_valid_project(name:, identifier:)
5561
context "when a project has underscores in its identifier" do
5662
before { create_valid_project(name: "My Project", identifier: "my_proj") }
5763

58-
it "does not flag it as problematic" do
59-
expect(result.total_count).to eq(0)
60-
expect(result.projects_data).to be_empty
64+
it "flags it as problematic (underscores are not valid in semantic identifiers)" do
65+
expect(result.total_count).to eq(1)
66+
expect(result.projects_data.first[:error_reason]).to eq(:special_characters)
6167
end
6268
end
6369

@@ -126,5 +132,33 @@ def create_valid_project(name:, identifier:)
126132
create_problematic_project(name: "Test", identifier: "my-very-long-identifier")
127133
expect(result.projects_data.first[:error_reason]).to eq(:too_long)
128134
end
135+
136+
it "assigns :numerical when identifier is purely numeric" do
137+
create_problematic_project(name: "Test", identifier: "12345")
138+
expect(result.projects_data.first[:error_reason]).to eq(:numerical)
139+
end
140+
141+
it "assigns :starts_with_number when identifier begins with a digit" do
142+
create_problematic_project(name: "Test", identifier: "1abc")
143+
expect(result.projects_data.first[:error_reason]).to eq(:starts_with_number)
144+
end
145+
146+
it "assigns :not_uppercase when identifier is lowercase but otherwise valid" do
147+
create_problematic_project(name: "Test", identifier: "proj")
148+
expect(result.projects_data.first[:error_reason]).to eq(:not_uppercase)
149+
end
150+
151+
it "assigns :unknown when an identifier in the scope matches no known classification" do
152+
project = create_valid_project(name: "Oddball", identifier: "ODDBALL")
153+
154+
# Simulate a new problematic_scope condition that catches a project
155+
# not covered by any error_reason branch (drift scenario).
156+
query = described_class.new
157+
forced_scope = Project.where(id: project.id)
158+
allow(query).to receive(:problematic_scope).and_return(forced_scope)
159+
160+
result = query.call
161+
expect(result.projects_data.first[:error_reason]).to eq(:unknown)
162+
end
129163
end
130164
end

0 commit comments

Comments
 (0)