Skip to content

Commit 26c4a50

Browse files
committed
feat: update custom fields handling to support numeric arrays and filters
- Refactor customFieldsToPayload to handle numeric arrays and optional values. - Introduce customFieldFiltersToPayload for handling various filter types (text, numeric, date, list). - Update tests to cover new functionality for custom fields and filters. - Modify addIssue, countIssues, getIssues, and updateIssue tools to utilize new filter schema. - Enhance validation for custom field filters with Zod schema. - Update package dependencies and scripts for improved development experience.
1 parent a11f859 commit 26c4a50

13 files changed

+888
-66
lines changed

package-lock.json

Lines changed: 472 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
},
88
"license": "MIT",
99
"scripts": {
10-
"dev": "NODE_ENV=development node --loader ts-node/esm src/index.ts",
10+
"dev": "tsx src/index.ts",
1111
"prebuild": "node scripts/replace-version.js",
1212
"build": "tsc && chmod 755 build/index.js",
1313
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
@@ -22,7 +22,7 @@
2222
],
2323
"dependencies": {
2424
"@modelcontextprotocol/sdk": "^1.9.0",
25-
"backlog-js": "^0.13.6",
25+
"backlog-js": "^0.15.0",
2626
"cosmiconfig": "^9.0.0",
2727
"dotenv": "^16.5.0",
2828
"env-var": "^7.5.0",
@@ -35,6 +35,7 @@
3535
},
3636
"devDependencies": {
3737
"@eslint/js": "^9.24.0",
38+
"tsx": "^4.20.6",
3839
"@release-it/conventional-changelog": "^10.0.1",
3940
"@types/jest": "^29.5.14",
4041
"@types/node": "^22.14.1",

src/backlog/customFields.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
customFieldsToPayload,
3+
customFieldFiltersToPayload,
34
type CustomFieldInput,
5+
type CustomFieldFilterInput,
46
} from './customFields.js';
57
import { describe, it, expect } from '@jest/globals';
68

@@ -46,6 +48,32 @@ describe('customFieldsToPayload', () => {
4648
});
4749
});
4850

51+
it('converts fields with numeric array values', () => {
52+
const input: CustomFieldInput[] = [
53+
{
54+
id: 150,
55+
value: [1, 2, 3],
56+
},
57+
];
58+
const result = customFieldsToPayload(input);
59+
expect(result).toEqual({
60+
customField_150: [1, 2, 3],
61+
});
62+
});
63+
64+
it('supports otherValue when value is undefined', () => {
65+
const input: CustomFieldInput[] = [
66+
{
67+
id: 160,
68+
otherValue: '自由入力',
69+
},
70+
];
71+
const result = customFieldsToPayload(input);
72+
expect(result).toEqual({
73+
customField_160_otherValue: '自由入力',
74+
});
75+
});
76+
4977
it('converts multiple fields of mixed types', () => {
5078
const input: CustomFieldInput[] = [
5179
{ id: 201, value: 'text' },
@@ -61,3 +89,54 @@ describe('customFieldsToPayload', () => {
6189
});
6290
});
6391
});
92+
93+
describe('customFieldFiltersToPayload', () => {
94+
it('returns empty object when input is undefined', () => {
95+
expect(customFieldFiltersToPayload(undefined)).toEqual({});
96+
});
97+
98+
it('handles text filters', () => {
99+
const filters: CustomFieldFilterInput[] = [
100+
{ id: 100, type: 'text', value: 'keyword' },
101+
];
102+
expect(customFieldFiltersToPayload(filters)).toEqual({
103+
customField_100: 'keyword',
104+
});
105+
});
106+
107+
it('handles numeric filters with min/max', () => {
108+
const filters: CustomFieldFilterInput[] = [
109+
{ id: 200, type: 'numeric', min: 5, max: 10 },
110+
];
111+
expect(customFieldFiltersToPayload(filters)).toEqual({
112+
customField_200_min: 5,
113+
customField_200_max: 10,
114+
});
115+
});
116+
117+
it('handles date filters', () => {
118+
const filters: CustomFieldFilterInput[] = [
119+
{
120+
id: 300,
121+
type: 'date',
122+
min: '2024-01-01',
123+
max: '2024-12-31',
124+
},
125+
];
126+
expect(customFieldFiltersToPayload(filters)).toEqual({
127+
customField_300_min: '2024-01-01',
128+
customField_300_max: '2024-12-31',
129+
});
130+
});
131+
132+
it('handles list filters with single and multiple values', () => {
133+
const filters: CustomFieldFilterInput[] = [
134+
{ id: 400, type: 'list', value: 1 },
135+
{ id: 401, type: 'list', value: [2, 3] },
136+
];
137+
expect(customFieldFiltersToPayload(filters)).toEqual({
138+
customField_400: 1,
139+
'customField_401[]': [2, 3],
140+
});
141+
});
142+
});

src/backlog/customFields.ts

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,111 @@
11
export type CustomFieldInput = {
22
id: number;
3-
value: string | number | string[];
3+
value?: string | number | string[] | number[];
44
otherValue?: string;
55
};
66

7+
export type CustomFieldFilterInput =
8+
| {
9+
id: number;
10+
type: 'text';
11+
value: string;
12+
}
13+
| {
14+
id: number;
15+
type: 'numeric';
16+
min?: number;
17+
max?: number;
18+
}
19+
| {
20+
id: number;
21+
type: 'date';
22+
min?: string;
23+
max?: string;
24+
}
25+
| {
26+
id: number;
27+
type: 'list';
28+
value: number | number[];
29+
};
30+
731
/**
832
* Converts Backlog-style customFields array into proper payload format
933
*/
1034
export function customFieldsToPayload(
1135
customFields: CustomFieldInput[] | undefined
12-
): Record<string, string | number | string[] | undefined> {
36+
): Record<string, string | number | string[] | number[]| undefined> {
1337
if (customFields == null) {
1438
return {};
1539
}
16-
const result: Record<string, string | number | string[] | undefined> = {};
40+
const result: Record<string, string | number | string[] | number[] | undefined> = {};
1741

1842
for (const field of customFields) {
19-
result[`customField_${field.id}`] = field.value;
20-
if (field.otherValue) {
43+
if(field.value !== undefined){
44+
result[`customField_${field.id}`] = field.value;
45+
}
46+
if(field.otherValue !== undefined) {
2147
result[`customField_${field.id}_otherValue`] = field.otherValue;
2248
}
2349
}
2450

2551
return result;
2652
}
53+
54+
export function customFieldFiltersToPayload(
55+
customFields: CustomFieldFilterInput[] | undefined
56+
): Record<string, string | number | number[] | undefined> {
57+
if (!customFields || customFields.length === 0) {
58+
return {};
59+
}
60+
61+
const result: Record<string, string | number | number[] | undefined> = {};
62+
63+
for (const field of customFields) {
64+
const baseKey = `customField_${field.id}`;
65+
66+
switch (field.type) {
67+
case 'text': {
68+
if (field.value.trim().length > 0) {
69+
result[baseKey] = field.value;
70+
}
71+
break;
72+
}
73+
case 'numeric': {
74+
if (field.min !== undefined) {
75+
result[`${baseKey}_min`] = field.min;
76+
}
77+
if (field.max !== undefined) {
78+
result[`${baseKey}_max`] = field.max;
79+
}
80+
break;
81+
}
82+
case 'date': {
83+
if (field.min) {
84+
result[`${baseKey}_min`] = field.min;
85+
}
86+
if (field.max) {
87+
result[`${baseKey}_max`] = field.max;
88+
}
89+
break;
90+
}
91+
case 'list': {
92+
if (Array.isArray(field.value)) {
93+
const values = field.value.filter((value) => Number.isFinite(value));
94+
if (values.length > 0) {
95+
result[`${baseKey}[]`] = values;
96+
}
97+
} else if (Number.isFinite(field.value)) {
98+
result[baseKey] = field.value;
99+
}
100+
break;
101+
}
102+
default: {
103+
const exhaustiveCheck: never = field;
104+
throw new Error(`Unsupported custom field filter type: ${exhaustiveCheck}`);
105+
}
106+
}
107+
}
108+
109+
return result;
110+
}
111+

src/tools/addIssue.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,9 @@ describe('addIssueTool', () => {
122122
issueTypeId: 2,
123123
priorityId: 3,
124124
customFields: [
125-
{ id: 123, value: 'テキスト' },
125+
{ id: 123, value: 987 },
126126
{ id: 456, value: 42 },
127-
{ id: 789, value: ['OptionA', 'OptionB'], otherValue: '詳細説明' },
127+
{ id: 789, value: [11, 22], otherValue: '詳細説明' },
128128
],
129129
});
130130

@@ -134,11 +134,45 @@ describe('addIssueTool', () => {
134134
summary: 'Custom Field Test',
135135
issueTypeId: 2,
136136
priorityId: 3,
137-
customField_123: 'テキスト',
137+
customField_123: 987,
138138
customField_456: 42,
139-
customField_789: ['OptionA', 'OptionB'],
139+
customField_789: [11, 22],
140140
customField_789_otherValue: '詳細説明',
141141
})
142142
);
143143
});
144+
145+
it('transforms multi-select customFields with numeric IDs', async () => {
146+
await tool.handler({
147+
projectId: 100,
148+
summary: 'Multi-select Test',
149+
issueTypeId: 2,
150+
priorityId: 3,
151+
customFields: [{ id: 555, value: [10, 20, 30] }],
152+
});
153+
154+
expect(mockBacklog.postIssue).toHaveBeenCalledWith(
155+
expect.objectContaining({
156+
projectId: 100,
157+
customField_555: [10, 20, 30],
158+
})
159+
);
160+
});
161+
162+
it('transforms customFields that provide only otherValue', async () => {
163+
await tool.handler({
164+
projectId: 100,
165+
summary: 'Other value only',
166+
issueTypeId: 2,
167+
priorityId: 3,
168+
customFields: [{ id: 777, otherValue: '自由入力' }],
169+
});
170+
171+
expect(mockBacklog.postIssue).toHaveBeenCalledWith(
172+
expect.objectContaining({
173+
projectId: 100,
174+
customField_777_otherValue: '自由入力',
175+
})
176+
);
177+
});
144178
});

src/tools/addIssue.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ const addIssueSchema = buildToolSchema((t) => ({
7979
'The ID of the custom field (e.g., 12345)'
8080
)
8181
),
82-
value: z.union([z.string().max(255), z.number(), z.array(z.string())]),
83-
otherValue: z
82+
value: z.union([z.number(), z.array(z.number())]).optional()
83+
.describe("The ID(s) of the custom field item. For single-select fields, provide a number. For multi-select fields, provide an array of numbers representing the selected item IDs."),
84+
otherValue: z
8485
.string()
8586
.optional()
8687
.describe(

src/tools/countIssues.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,26 @@ describe('countIssuesTool', () => {
4949
await tool.handler({
5050
projectId: [100],
5151
customFields: [
52-
{ id: 12345, value: 'test-value' },
53-
{ id: 67890, value: 123 },
52+
{ id: 12345, type: 'text', value: 'test-value' },
53+
{ id: 67890, type: 'numeric', min: 1, max: 5 },
5454
],
5555
});
5656

5757
expect(mockBacklog.getIssuesCount).toHaveBeenCalledWith({
5858
projectId: [100],
5959
customField_12345: 'test-value',
60-
customField_67890: 123,
60+
customField_67890_min: 1,
61+
customField_67890_max: 5,
6162
});
6263
});
6364

6465
it('calls backlog.getIssuesCount with custom fields array values', async () => {
6566
await tool.handler({
66-
customFields: [{ id: 11111, value: ['option1', 'option2'] }],
67+
customFields: [{ id: 11111, type: 'list', value: [7, 8] }],
6768
});
6869

6970
expect(mockBacklog.getIssuesCount).toHaveBeenCalledWith({
70-
customField_11111: ['option1', 'option2'],
71+
'customField_11111[]': [7, 8],
7172
});
7273
});
7374

src/tools/countIssues.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { Backlog } from 'backlog-js';
33
import { buildToolSchema, ToolDefinition } from '../types/tool.js';
44
import { TranslationHelper } from '../createTranslationHelper.js';
55
import { IssueCountSchema } from '../types/zod/backlogOutputDefinition.js';
6-
import { customFieldsToPayload } from '../backlog/customFields.js';
6+
import { customFieldFiltersToPayload } from '../backlog/customFields.js';
7+
import { buildCustomFieldFilterSchema } from './shared/customFieldFiltersSchema.js';
78

89
const countIssuesSchema = buildToolSchema((t) => ({
910
projectId: z
@@ -105,20 +106,14 @@ const countIssuesSchema = buildToolSchema((t) => ({
105106
t('TOOL_COUNT_ISSUES_UPDATED_UNTIL', 'Updated until (yyyy-MM-dd)')
106107
),
107108
customFields: z
108-
.array(
109-
z.object({
110-
id: z
111-
.number()
112-
.describe(t('TOOL_COUNT_ISSUES_CUSTOM_FIELD_ID', 'Custom field ID')),
113-
value: z
114-
.union([z.string(), z.number(), z.array(z.string())])
115-
.describe(
116-
t('TOOL_COUNT_ISSUES_CUSTOM_FIELD_VALUE', 'Custom field value')
117-
),
118-
})
119-
)
120-
.optional()
121-
.describe(t('TOOL_COUNT_ISSUES_CUSTOM_FIELDS', 'Custom fields')),
109+
.array(buildCustomFieldFilterSchema(t))
110+
.optional()
111+
.describe(
112+
t(
113+
'TOOL_COUNT_ISSUES_CUSTOM_FIELDS',
114+
'Custom field filters (text, numeric, date, or list)'
115+
)
116+
),
122117
}));
123118

124119
export const countIssuesTool = (
@@ -136,7 +131,7 @@ export const countIssuesTool = (
136131
handler: async ({ customFields, ...rest }) => {
137132
return backlog.getIssuesCount({
138133
...rest,
139-
...customFieldsToPayload(customFields),
134+
...customFieldFiltersToPayload(customFields),
140135
});
141136
},
142137
};

0 commit comments

Comments
 (0)