feat: custom fields per project

Text, number, date or option-list fields defined in the project settings
(archived, never removed; their type is fixed once saved). Tickets hold
them, edited in the side panel, listed under the title and filtered by
option in the list view; also over GraphQL, REST (?field=ID:value) and MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 09:31:12 +02:00
parent 233c46e9fa
commit f7e850068b
44 changed files with 1909 additions and 96 deletions

View File

@@ -1168,4 +1168,115 @@ describe('task-stream (e2e)', () => {
history.body.map((h: { actor: { name: string } }) => h.actor.name),
).toContain('TaskStream');
});
it('adds custom fields to a project and lets tickets hold and filter by them', async () => {
const key = 'C' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const settings = project.body['settings'];
expect(settings.customFields).toEqual([]);
// REST: define them (a client that leaves them out keeps them).
const fields = [
{
id: 'BROWSER',
name: 'Navegador',
type: 'SELECT',
options: ['Chrome', 'Safari'],
},
{ id: 'COST', name: 'Coste', type: 'NUMBER' },
];
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...settings,
customFields: fields,
})
).status,
).toBe(200);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.body['settings'].customFields.length === 2,
);
const { customFields: _left, ...older } = settings;
expect(
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, older))
.status,
).toBe(200);
// REST: a ticket with values; a wrong one is refused.
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Pago roto',
customFields: { BROWSER: 'Safari', COST: '1,5' },
});
expect(created.status).toBe(201);
const ticket = created.body['key'] as string;
const issue = await eventually(
() => rest('token-ana', 'GET', `/issues/${ticket}`),
(r) => r.status === 200,
);
expect(issue.body['customFields']).toEqual({
BROWSER: 'Safari',
COST: '1.5',
});
expect(
(
await rest('token-ana', 'PATCH', `/issues/${ticket}`, {
customFields: { BROWSER: 'Edge' },
})
).status,
).toBe(400);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Otro',
customFields: { BROWSER: 'Chrome' },
});
// GraphQL: change one, clear another; filter by value.
await gql(
'token-ana',
`mutation($i: UpdateIssueInput!) { updateIssue(input: $i) { version } }`,
{
i: {
issueId: issue.body['id'],
customFields: [
{ fieldId: 'BROWSER', value: 'Chrome' },
{ fieldId: 'COST', value: null },
],
},
},
);
const filtered = await eventually(
() =>
gql(
'token-ana',
`query($f: IssueFilterInput!) { issues(filter: $f) { total items { key customFields { fieldId value } } } }`,
{
f: {
projectId: project.body['id'],
customFields: [{ fieldId: 'BROWSER', value: 'Chrome' }],
},
},
),
(d) => d['issues'].total === 2,
);
expect(
filtered['issues'].items.find((i: { key: string }) => i.key === ticket)
.customFields,
).toEqual([{ fieldId: 'BROWSER', value: 'Chrome' }]);
const bySafari = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?field=BROWSER:Safari`,
);
expect(bySafari.body['total']).toBe(0);
const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
expect(
history.body.map((h: { summary: string }) => h.summary).join(' | '),
).toContain('los campos personalizados');
});
});

View File

@@ -2424,6 +2424,21 @@
"type": "string"
}
},
{
"name": "field",
"required": false,
"in": "query",
"description": "Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several",
"schema": {
"example": [
"BROWSER:Safari"
],
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "sort",
"required": false,
@@ -10460,6 +10475,54 @@
"archived"
]
},
"CustomFieldType": {
"type": "string",
"enum": [
"TEXT",
"NUMBER",
"DATE",
"SELECT"
],
"description": "DATE values are YYYY-MM-DD"
},
"CustomFieldDefinitionDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "BROWSER"
},
"name": {
"type": "string",
"example": "Navegador"
},
"type": {
"description": "DATE values are YYYY-MM-DD",
"allOf": [
{
"$ref": "#/components/schemas/CustomFieldType"
}
]
},
"options": {
"description": "The choices of a SELECT field",
"type": "array",
"items": {
"type": "string"
}
},
"archived": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"type",
"options",
"archived"
]
},
"ProjectSettingsDto": {
"type": "object",
"properties": {
@@ -10487,13 +10550,20 @@
"items": {
"$ref": "#/components/schemas/AreaOptionDto"
}
},
"customFields": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomFieldDefinitionDto"
}
}
},
"required": [
"statuses",
"transitions",
"issueTypes",
"areas"
"areas",
"customFields"
]
},
"ProjectDto": {
@@ -10799,6 +10869,50 @@
"name"
]
},
"CustomFieldDefinitionBodyDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "BROWSER"
},
"name": {
"type": "string",
"example": "Navegador",
"maxLength": 40
},
"type": {
"description": "Cannot change once the field exists. DATE: YYYY-MM-DD",
"allOf": [
{
"$ref": "#/components/schemas/CustomFieldType"
}
]
},
"options": {
"maxItems": 50,
"example": [
"Chrome",
"Safari",
"Firefox"
],
"description": "The choices of a SELECT field",
"type": "array",
"items": {
"type": "string"
}
},
"archived": {
"type": "boolean",
"default": false
}
},
"required": [
"id",
"name",
"type"
]
},
"ProjectSettingsBodyDto": {
"type": "object",
"properties": {
@@ -10826,6 +10940,13 @@
"items": {
"$ref": "#/components/schemas/AreaOptionBodyDto"
}
},
"customFields": {
"description": "Absent: unchanged. Fields cannot be removed: archive them.",
"type": "array",
"items": {
"$ref": "#/components/schemas/CustomFieldDefinitionBodyDto"
}
}
},
"required": [
@@ -11216,6 +11337,16 @@
"nullable": true,
"format": "date"
},
"customFields": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"example": {
"BROWSER": "Safari"
},
"description": "Custom field id → value (only those set)"
},
"links": {
"type": "array",
"items": {
@@ -11321,6 +11452,7 @@
"storyPoints",
"startDate",
"dueDate",
"customFields",
"links",
"attachments",
"watchers",
@@ -11823,6 +11955,17 @@
"nullable": true,
"format": "date"
},
"customFields": {
"type": "object",
"additionalProperties": {
"type": "string",
"nullable": true
},
"example": {
"BROWSER": "Safari"
},
"description": "Custom field id → value. Only the fields to change; null or '' clears one"
},
"type": {
"type": "string",
"example": "BUG",
@@ -11950,6 +12093,17 @@
"nullable": true,
"format": "date"
},
"customFields": {
"type": "object",
"additionalProperties": {
"type": "string",
"nullable": true
},
"example": {
"BROWSER": "Safari"
},
"description": "Custom field id → value. Only the fields to change; null or '' clears one"
},
"type": {
"type": "string",
"example": "FEATURE",

View File

@@ -279,6 +279,9 @@ input CreateIssueInput {
"""An area id"""
area: String
assigneeId: ID
"""Only the fields to change"""
customFields: [CustomFieldValueInput!]
description: String
dueDate: String
@@ -352,6 +355,47 @@ type CumulativeFlow {
series: [FlowSeries!]!
}
"""A field the project adds to its tickets; its type never changes"""
type CustomFieldDefinition {
archived: Boolean!
id: String!
name: String!
"""The choices of a SELECT field"""
options: [String!]!
type: CustomFieldType!
}
input CustomFieldDefinitionInput {
archived: Boolean! = false
id: String!
name: String!
"""The choices of a SELECT field"""
options: [String!]! = []
type: CustomFieldType!
}
"""What a custom field holds (DATE: YYYY-MM-DD)"""
enum CustomFieldType {
DATE
NUMBER
SELECT
TEXT
}
"""A ticket's value of a field"""
type CustomFieldValue {
fieldId: String!
value: String!
}
"""A custom field's value; null or '' clears it"""
input CustomFieldValueInput {
fieldId: String!
value: String
}
"""A commit, branch or pull request that mentions the ticket"""
type DevLink {
at: String!
@@ -443,6 +487,9 @@ type Issue {
commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
createdAt: String!
"""Values of the project's custom fields (only those set)"""
customFields: [CustomFieldValue!]!
description: String
"""Commits, branches and pull requests that mention it"""
@@ -516,6 +563,9 @@ input IssueFilterInput {
assigneeId: ID
categories: [StatusCategory!]
"""Only tickets holding these custom values"""
customFields: [CustomFieldValueInput!]
"""Due on or after this day (YYYY-MM-DD)"""
dueFrom: String
@@ -961,9 +1011,12 @@ enum ProjectRole {
VIEWER
}
"""Statuses (board order), transitions, ticket types and areas"""
"""
Statuses (board order), transitions, ticket types, areas and custom fields
"""
type ProjectSettings {
areas: [AreaOption!]!
customFields: [CustomFieldDefinition!]!
issueTypes: [IssueTypeOption!]!
statuses: [WorkflowStatus!]!
transitions: [StatusTransitions!]!
@@ -974,6 +1027,9 @@ The whole configuration (replaces the previous one). Statuses go in board order.
"""
input ProjectSettingsInput {
areas: [AreaOptionInput!]!
"""Absent: unchanged. Fields cannot be removed: archive them."""
customFields: [CustomFieldDefinitionInput!]
issueTypes: [IssueTypeOptionInput!]!
statuses: [WorkflowStatusInput!]!
transitions: [StatusTransitionsInput!]!
@@ -1327,6 +1383,9 @@ input UpdateChecklistItemInput {
input UpdateIssueInput {
"""An area id"""
area: String
"""Only the fields to change"""
customFields: [CustomFieldValueInput!]
description: String
dueDate: String

View File

@@ -1666,12 +1666,29 @@ export interface components {
name: string;
archived: boolean;
};
/**
* @description DATE values are YYYY-MM-DD
* @enum {string}
*/
CustomFieldType: "TEXT" | "NUMBER" | "DATE" | "SELECT";
CustomFieldDefinitionDto: {
/** @example BROWSER */
id: string;
/** @example Navegador */
name: string;
/** @description DATE values are YYYY-MM-DD */
type: components["schemas"]["CustomFieldType"];
/** @description The choices of a SELECT field */
options: string[];
archived: boolean;
};
ProjectSettingsDto: {
/** @description In board order */
statuses: components["schemas"]["WorkflowStatusDto"][];
transitions: components["schemas"]["StatusTransitionsDto"][];
issueTypes: components["schemas"]["IssueTypeOptionDto"][];
areas: components["schemas"]["AreaOptionDto"][];
customFields: components["schemas"]["CustomFieldDefinitionDto"][];
};
ProjectDto: {
/** Format: uuid */
@@ -1788,12 +1805,33 @@ export interface components {
/** @default false */
archived: boolean;
};
CustomFieldDefinitionBodyDto: {
/** @example BROWSER */
id: string;
/** @example Navegador */
name: string;
/** @description Cannot change once the field exists. DATE: YYYY-MM-DD */
type: components["schemas"]["CustomFieldType"];
/**
* @description The choices of a SELECT field
* @example [
* "Chrome",
* "Safari",
* "Firefox"
* ]
*/
options?: string[];
/** @default false */
archived: boolean;
};
ProjectSettingsBodyDto: {
/** @description In board order. Values cannot be removed: archive them instead. */
statuses: components["schemas"]["WorkflowStatusBodyDto"][];
transitions: components["schemas"]["StatusTransitionsBodyDto"][];
issueTypes: components["schemas"]["IssueTypeOptionBodyDto"][];
areas: components["schemas"]["AreaOptionBodyDto"][];
/** @description Absent: unchanged. Fields cannot be removed: archive them. */
customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][];
};
/** @enum {string} */
IssuePriority: "LOWEST" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
@@ -1930,6 +1968,15 @@ export interface components {
startDate: string | null;
/** Format: date */
dueDate: string | null;
/**
* @description Custom field id → value (only those set)
* @example {
* "BROWSER": "Safari"
* }
*/
customFields: {
[key: string]: string;
};
links: components["schemas"]["IssueLinkDto"][];
attachments: components["schemas"]["AttachmentDto"][];
/** @description People following it */
@@ -2098,6 +2145,15 @@ export interface components {
startDate?: string | null;
/** Format: date */
dueDate?: string | null;
/**
* @description Custom field id → value. Only the fields to change; null or '' clears one
* @example {
* "BROWSER": "Safari"
* }
*/
customFields?: {
[key: string]: string | null;
};
/**
* @description A ticket type id of the project's settings
* @example BUG
@@ -2146,6 +2202,15 @@ export interface components {
startDate?: string | null;
/** Format: date */
dueDate?: string | null;
/**
* @description Custom field id → value. Only the fields to change; null or '' clears one
* @example {
* "BROWSER": "Safari"
* }
*/
customFields?: {
[key: string]: string | null;
};
/**
* @description A ticket type id
* @example FEATURE
@@ -4365,6 +4430,8 @@ export interface operations {
dueFrom?: string;
/** @description Due on or before this day */
dueTo?: string;
/** @description Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several */
field?: string[];
/** @description RECENT (default): last updated first. RANK: backlog order. */
sort?: components["schemas"]["IssueSort"];
/** @description Matches key, title or description */

View File

@@ -46,6 +46,12 @@ const issueFields = {
.optional()
.describe('ISO date when work should start (roadmap)'),
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
customFields: z
.record(z.string(), z.string().nullable())
.optional()
.describe(
'Custom field id → value (see get_project settings.customFields): SELECT one of its options, DATE YYYY-MM-DD, NUMBER a number. Only the fields to change; null clears one',
),
};
type IssueFields = {
@@ -57,6 +63,7 @@ type IssueFields = {
storyPoints?: number;
startDate?: string;
dueDate?: string;
customFields?: Record<string, string | null>;
};
@Resolver()
@@ -142,6 +149,12 @@ export class IssuesResolver {
.describe('Only the tickets the user follows'),
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
customFields: z
.record(z.string(), z.string())
.optional()
.describe(
'Only tickets holding these custom values (field id → value)',
),
sort: z
.enum(['RECENT', 'RANK'])
.default('RECENT')
@@ -170,6 +183,7 @@ export class IssuesResolver {
watching?: boolean;
dueFrom?: string;
dueTo?: string;
customFields?: Record<string, string>;
sort: 'RECENT' | 'RANK';
limit: number;
offset: number;
@@ -198,6 +212,11 @@ export class IssuesResolver {
watching: params.watching,
dueFrom: params.dueFrom,
dueTo: params.dueTo,
field: params.customFields
? Object.entries(params.customFields).map(
([id, value]) => `${id}:${value}`,
)
: undefined,
sort: params.sort,
limit: params.limit,
offset: params.offset,

View File

@@ -27,6 +27,7 @@ export const issueLine = (issue: Issue) => ({
votes: issue.votes,
storyPoints: issue.storyPoints,
dueDate: issue.dueDate,
customFields: issue.customFields,
labels: issue.labels,
updatedAt: issue.updatedAt,
});