@opentask/taskin-task-manager 3.1.0 → 3.2.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.
- package/CHANGELOG.md +62 -0
- package/dist/completion-blockers.test.d.ts +1 -0
- package/dist/completion-blockers.test.js +57 -0
- package/dist/completion-blockers.test.js.map +1 -0
- package/dist/filter-tasks/filter-criteria.d.ts +181 -0
- package/dist/filter-tasks/filter-criteria.js +115 -0
- package/dist/filter-tasks/filter-criteria.js.map +1 -0
- package/dist/filter-tasks/filter-criteria.test.d.ts +1 -0
- package/dist/filter-tasks/filter-criteria.test.js +86 -0
- package/dist/filter-tasks/filter-criteria.test.js.map +1 -0
- package/dist/filter-tasks/filter-tasks.js +17 -2
- package/dist/filter-tasks/filter-tasks.js.map +1 -1
- package/dist/filter-tasks/filter-tasks.test.js +38 -0
- package/dist/filter-tasks/filter-tasks.test.js.map +1 -1
- package/dist/filter-tasks/filter-tasks.types.d.ts +5 -23
- package/dist/filter-tasks/index.d.ts +3 -1
- package/dist/filter-tasks/index.js +1 -0
- package/dist/filter-tasks/index.js.map +1 -1
- package/dist/group-registry.contract.d.ts +18 -0
- package/dist/group-registry.contract.js +73 -0
- package/dist/group-registry.contract.js.map +1 -0
- package/dist/group-registry.types.d.ts +44 -0
- package/dist/group-registry.types.js +2 -0
- package/dist/group-registry.types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/numerar-prioridade/index.d.ts +1 -0
- package/dist/numerar-prioridade/index.js +2 -0
- package/dist/numerar-prioridade/index.js.map +1 -0
- package/dist/numerar-prioridade/numerar-prioridade.d.ts +36 -0
- package/dist/numerar-prioridade/numerar-prioridade.js +75 -0
- package/dist/numerar-prioridade/numerar-prioridade.js.map +1 -0
- package/dist/numerar-prioridade/numerar-prioridade.test.d.ts +1 -0
- package/dist/numerar-prioridade/numerar-prioridade.test.js +55 -0
- package/dist/numerar-prioridade/numerar-prioridade.test.js.map +1 -0
- package/dist/ordenar-tarefas/index.d.ts +1 -0
- package/dist/ordenar-tarefas/index.js +2 -0
- package/dist/ordenar-tarefas/index.js.map +1 -0
- package/dist/ordenar-tarefas/ordenar-tarefas.d.ts +70 -0
- package/dist/ordenar-tarefas/ordenar-tarefas.js +79 -0
- package/dist/ordenar-tarefas/ordenar-tarefas.js.map +1 -0
- package/dist/ordenar-tarefas/ordenar-tarefas.test.d.ts +1 -0
- package/dist/ordenar-tarefas/ordenar-tarefas.test.js +70 -0
- package/dist/ordenar-tarefas/ordenar-tarefas.test.js.map +1 -0
- package/dist/task-manager.d.ts +26 -1
- package/dist/task-manager.js +48 -0
- package/dist/task-manager.js.map +1 -1
- package/dist/task-manager.types.d.ts +82 -0
- package/dist/testing.d.ts +8 -0
- package/dist/testing.js +9 -0
- package/dist/testing.js.map +1 -0
- package/dist/user-registry.contract.js +6 -6
- package/dist/user-registry.contract.js.map +1 -1
- package/package.json +7 -6
- package/src/completion-blockers.test.ts +77 -0
- package/src/filter-tasks/filter-criteria.test.ts +113 -0
- package/src/filter-tasks/filter-criteria.ts +175 -0
- package/src/filter-tasks/filter-tasks.test.ts +45 -0
- package/src/filter-tasks/filter-tasks.ts +17 -2
- package/src/filter-tasks/filter-tasks.types.ts +5 -23
- package/src/filter-tasks/index.ts +15 -1
- package/src/group-registry.contract.ts +99 -0
- package/src/group-registry.types.ts +47 -0
- package/src/index.ts +3 -0
- package/src/numerar-prioridade/index.ts +1 -0
- package/src/numerar-prioridade/numerar-prioridade.test.ts +69 -0
- package/src/numerar-prioridade/numerar-prioridade.ts +80 -0
- package/src/ordenar-tarefas/index.ts +1 -0
- package/src/ordenar-tarefas/ordenar-tarefas.test.ts +92 -0
- package/src/ordenar-tarefas/ordenar-tarefas.ts +135 -0
- package/src/task-manager.ts +59 -0
- package/src/task-manager.types.ts +83 -0
- package/src/testing.ts +8 -0
- package/src/user-registry.contract.ts +6 -6
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { TaskStatusSchema, TaskTypeSchema } from '@opentask/taskin-types';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Os criterios que restringem uma listagem, definidos **uma vez so**.
|
|
6
|
+
*
|
|
7
|
+
* Antes cada criterio existia em cinco lugares escritos a mao: o tipo, a flag da
|
|
8
|
+
* CLI, o mapeamento da CLI, o schema JSON do MCP e o mapeamento do MCP.
|
|
9
|
+
* Acrescentar um exigia lembrar dos cinco, e esquecer nao quebrava nada — so
|
|
10
|
+
* fazia uma superficie ficar para tras, em silencio. O sintoma ja tinha
|
|
11
|
+
* aparecido: o criterio chamava-se `assignee` e a flag da CLI chamava-se
|
|
12
|
+
* `--user`, duas grafias para a mesma coisa que ninguem decidiu.
|
|
13
|
+
*
|
|
14
|
+
* Aqui o schema e a fonte. As superficies **derivam** dele:
|
|
15
|
+
*
|
|
16
|
+
* - `TaskFilterCriteria` e `z.infer` deste schema — o tipo nao se escreve a mao.
|
|
17
|
+
* - O schema JSON do `list_tasks` sai de `filterCriteriaJsonSchema`.
|
|
18
|
+
* - As opcoes do `taskin list` saem de `filterCriteriaCliOptions`.
|
|
19
|
+
* - A validacao e a conversao saem de `parseFilterCriteria` — os dois
|
|
20
|
+
* mapeamentos a mao desaparecem.
|
|
21
|
+
*
|
|
22
|
+
* O portao que fecha o circuito e o `satisfies Record<keyof TaskFilterCriteria,
|
|
23
|
+
* ...>` em {@link FILTER_CRITERIA_SURFACES}: acrescentar um criterio ao schema e
|
|
24
|
+
* **nao** ligar a superficie vira erro de compilacao, que e o unico lembrete que
|
|
25
|
+
* nao depende de ninguem lembrar.
|
|
26
|
+
*
|
|
27
|
+
* **Como acrescentar um criterio novo.** Duas linhas, e o compilador cobra a
|
|
28
|
+
* segunda:
|
|
29
|
+
*
|
|
30
|
+
* 1. uma propriedade `.optional()` em {@link FilterCriteriaSchema};
|
|
31
|
+
* 2. a entrada correspondente em {@link FILTER_CRITERIA_SURFACES} (descricao +
|
|
32
|
+
* onde mora na CLI).
|
|
33
|
+
*
|
|
34
|
+
* A CLI, o schema JSON do MCP e a validacao passam a conhece-lo sem mais nenhuma
|
|
35
|
+
* edicao. Esquecer o passo 2 nao compila.
|
|
36
|
+
*/
|
|
37
|
+
export const FilterCriteriaSchema = z.object({
|
|
38
|
+
status: TaskStatusSchema.optional(),
|
|
39
|
+
type: TaskTypeSchema.optional(),
|
|
40
|
+
assignee: z.string().optional(),
|
|
41
|
+
open: z.boolean().optional(),
|
|
42
|
+
closed: z.boolean().optional(),
|
|
43
|
+
active: z.boolean().optional(),
|
|
44
|
+
text: z.string().optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* O que restringe uma listagem de tarefas.
|
|
49
|
+
*
|
|
50
|
+
* Todos os campos sao opcionais e **se somam**: informar dois exige os dois. Um
|
|
51
|
+
* criterio ausente nao restringe nada. Derivado de {@link FilterCriteriaSchema}
|
|
52
|
+
* — mudar o schema muda o tipo.
|
|
53
|
+
*
|
|
54
|
+
* @public
|
|
55
|
+
*/
|
|
56
|
+
export type TaskFilterCriteria = z.infer<typeof FilterCriteriaSchema>;
|
|
57
|
+
|
|
58
|
+
/** Onde um criterio aparece na linha de comando. */
|
|
59
|
+
export type CriterionCli = { readonly kind: 'positional' } | { readonly kind: 'flag'; readonly short?: string };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* O que uma superficie precisa saber de um criterio alem da sua forma: o texto
|
|
63
|
+
* de ajuda (compartilhado entre `--help` da CLI e a propriedade do MCP) e onde
|
|
64
|
+
* ele mora na linha de comando.
|
|
65
|
+
*/
|
|
66
|
+
export interface CriterionSurface {
|
|
67
|
+
readonly description: string;
|
|
68
|
+
readonly cli: CriterionCli;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A superficie de cada criterio, gateada a cobrir **todos** os campos de
|
|
73
|
+
* {@link TaskFilterCriteria}.
|
|
74
|
+
*
|
|
75
|
+
* O `satisfies Record<keyof TaskFilterCriteria, ...>` e o portao: acrescentar um
|
|
76
|
+
* criterio ao schema sem uma entrada aqui nao compila.
|
|
77
|
+
*/
|
|
78
|
+
export const FILTER_CRITERIA_SURFACES = {
|
|
79
|
+
status: { description: 'Exact status (pending, in-progress, done, ...)', cli: { kind: 'flag', short: 's' } },
|
|
80
|
+
type: { description: 'Exact type (feat, fix, chore, ...)', cli: { kind: 'flag', short: 't' } },
|
|
81
|
+
assignee: { description: 'Assignee id or name, whole or in part', cli: { kind: 'flag', short: 'u' } },
|
|
82
|
+
open: { description: 'Only tasks still open', cli: { kind: 'flag' } },
|
|
83
|
+
closed: { description: 'Only tasks already closed', cli: { kind: 'flag' } },
|
|
84
|
+
active: {
|
|
85
|
+
description: 'Only tasks started and not finished (in-progress, paused, in-review)',
|
|
86
|
+
cli: { kind: 'flag' },
|
|
87
|
+
},
|
|
88
|
+
text: { description: 'Free text over id, title, status and assignee', cli: { kind: 'positional' } },
|
|
89
|
+
} satisfies Record<keyof TaskFilterCriteria, CriterionSurface>;
|
|
90
|
+
|
|
91
|
+
type SurfaceMap = Record<string, CriterionSurface>;
|
|
92
|
+
|
|
93
|
+
/** O schema JSON de entrada de uma ferramenta MCP: objeto com propriedades. */
|
|
94
|
+
export interface FilterJsonSchema {
|
|
95
|
+
readonly type: 'object';
|
|
96
|
+
readonly properties: Record<string, unknown>;
|
|
97
|
+
readonly required: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Uma opcao de CLI, na forma que o `commander` (e o `defineCommand`) consome. */
|
|
101
|
+
export interface FilterCliOption {
|
|
102
|
+
readonly flags: string;
|
|
103
|
+
readonly description: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* O schema JSON das propriedades de filtro, derivado do zod.
|
|
108
|
+
*
|
|
109
|
+
* Nao e aposta: o `packages/types-ts` ja gera JSON Schema de schemas zod pelo
|
|
110
|
+
* mesmo `z.toJSONSchema()` desde a migracao para o zod 4. Aqui e o mesmo
|
|
111
|
+
* mecanismo, aplicado ao schema de filtro, com a descricao de cada campo vinda
|
|
112
|
+
* da superficie unica.
|
|
113
|
+
*
|
|
114
|
+
* Recebe schema e superficies para poder ser exercitado com um criterio
|
|
115
|
+
* ficticio nos testes — a prova de que deriva, em vez de repetir a mao.
|
|
116
|
+
*/
|
|
117
|
+
export function filterCriteriaJsonSchema(
|
|
118
|
+
schema: z.ZodObject = FilterCriteriaSchema,
|
|
119
|
+
surfaces: SurfaceMap = FILTER_CRITERIA_SURFACES,
|
|
120
|
+
): FilterJsonSchema {
|
|
121
|
+
const described = z.object(
|
|
122
|
+
Object.fromEntries(
|
|
123
|
+
Object.entries(schema.shape).map(([key, prop]) => [
|
|
124
|
+
key,
|
|
125
|
+
(prop as z.ZodType).describe(surfaces[key]?.description ?? ''),
|
|
126
|
+
]),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const json = z.toJSONSchema(described, { target: 'draft-7', io: 'output' }) as {
|
|
131
|
+
properties?: Record<string, unknown>;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return { type: 'object', properties: json.properties ?? {}, required: [] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* As opcoes do `taskin list`, derivadas das superficies.
|
|
139
|
+
*
|
|
140
|
+
* Um criterio `positional` (o texto livre) nao vira flag: ele e o argumento
|
|
141
|
+
* `[filter]` do comando. Um criterio booleano nao leva valor (`--open`); os
|
|
142
|
+
* demais levam (`-s, --status <status>`). O tipo — booleano ou nao — sai do
|
|
143
|
+
* proprio schema JSON, e nao de uma segunda lista.
|
|
144
|
+
*/
|
|
145
|
+
export function filterCriteriaCliOptions(
|
|
146
|
+
schema: z.ZodObject = FilterCriteriaSchema,
|
|
147
|
+
surfaces: SurfaceMap = FILTER_CRITERIA_SURFACES,
|
|
148
|
+
): FilterCliOption[] {
|
|
149
|
+
const { properties } = filterCriteriaJsonSchema(schema, surfaces);
|
|
150
|
+
const options: FilterCliOption[] = [];
|
|
151
|
+
|
|
152
|
+
for (const [key, surface] of Object.entries(surfaces)) {
|
|
153
|
+
if (surface.cli.kind !== 'flag') continue;
|
|
154
|
+
|
|
155
|
+
const short = surface.cli.short ? `-${surface.cli.short}, ` : '';
|
|
156
|
+
const takesValue = (properties[key] as { type?: string } | undefined)?.type !== 'boolean';
|
|
157
|
+
const flags = takesValue ? `${short}--${key} <${key}>` : `${short}--${key}`;
|
|
158
|
+
|
|
159
|
+
options.push({ flags, description: surface.description });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return options;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Valida os argumentos crus e os converte em {@link TaskFilterCriteria}.
|
|
167
|
+
*
|
|
168
|
+
* E o `parse` do proprio schema, sem mapeamento a mao. Chaves desconhecidas sao
|
|
169
|
+
* descartadas (o `--json` do CLI, por exemplo, nao e criterio); um valor com o
|
|
170
|
+
* tipo errado — um status fora do conjunto — passa a **falhar barulhento**, em
|
|
171
|
+
* vez de devolver lista vazia como se nao houvesse tarefa.
|
|
172
|
+
*/
|
|
173
|
+
export function parseFilterCriteria(raw: unknown): TaskFilterCriteria {
|
|
174
|
+
return FilterCriteriaSchema.parse(raw);
|
|
175
|
+
}
|
|
@@ -82,3 +82,48 @@ describe('filterTasks', () => {
|
|
|
82
82
|
expect(TAREFAS).toEqual(original);
|
|
83
83
|
});
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* "Em andamento" nao e `in-progress`, e nao e `open`.
|
|
88
|
+
*
|
|
89
|
+
* `open` inclui `pending` — o que ainda nao comecou, e que no painel e ruido.
|
|
90
|
+
* `in-progress` sozinho e estreito demais: uma tarefa some da tela no instante
|
|
91
|
+
* em que alguem a pausa ou a manda para revisao, o que e enganoso.
|
|
92
|
+
*
|
|
93
|
+
* `active` e o meio que faltava: **comecou e nao terminou**. `blocked` fica de
|
|
94
|
+
* fora por decisao declarada — ninguem esta trabalhando numa bloqueada agora.
|
|
95
|
+
*/
|
|
96
|
+
describe('filtro active', () => {
|
|
97
|
+
const tarefas = [
|
|
98
|
+
tarefa({ id: '001', status: 'pending' }),
|
|
99
|
+
tarefa({ id: '002', status: 'in-progress' }),
|
|
100
|
+
tarefa({ id: '003', status: 'paused' }),
|
|
101
|
+
tarefa({ id: '004', status: 'in-review' }),
|
|
102
|
+
tarefa({ id: '005', status: 'blocked' }),
|
|
103
|
+
tarefa({ id: '006', status: 'done' }),
|
|
104
|
+
tarefa({ id: '007', status: 'canceled' }),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
it('traz o que comecou e nao terminou', () => {
|
|
108
|
+
const ids = filterTasks(tarefas, { active: true }).map((t) => String(t.id));
|
|
109
|
+
|
|
110
|
+
expect(ids).toEqual(['002', '003', '004']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('deixa pending de fora — nao comecou', () => {
|
|
114
|
+
expect(filterTasks(tarefas, { active: true }).map((t) => String(t.id))).not.toContain('001');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('deixa blocked de fora, por decisao declarada', () => {
|
|
118
|
+
expect(filterTasks(tarefas, { active: true }).map((t) => String(t.id))).not.toContain('005');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('e mais estreito que open e mais largo que in-progress', () => {
|
|
122
|
+
const abertas = filterTasks(tarefas, { open: true }).length;
|
|
123
|
+
const ativas = filterTasks(tarefas, { active: true }).length;
|
|
124
|
+
const emProgresso = filterTasks(tarefas, { status: 'in-progress' }).length;
|
|
125
|
+
|
|
126
|
+
expect(ativas).toBeLessThan(abertas);
|
|
127
|
+
expect(ativas).toBeGreaterThan(emProgresso);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -12,8 +12,22 @@ const EM_ABERTO: readonly TaskStatus[] = ['pending', 'in-progress', 'paused', 'i
|
|
|
12
12
|
|
|
13
13
|
const ENCERRADOS: readonly TaskStatus[] = ['done', 'canceled'];
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Comecou e nao terminou.
|
|
17
|
+
*
|
|
18
|
+
* O meio que faltava entre `open` e `status: 'in-progress'`. `open` inclui
|
|
19
|
+
* `pending` — o que ainda nao comecou, e que num painel de acompanhamento e
|
|
20
|
+
* ruido. `in-progress` sozinho e estreito demais: a tarefa some da tela no
|
|
21
|
+
* instante em que alguem a pausa ou a manda para revisao.
|
|
22
|
+
*
|
|
23
|
+
* `blocked` fica de fora por decisao declarada: bloqueada e trabalho comecado,
|
|
24
|
+
* mas ninguem esta mexendo nela agora, e quem olha "o que esta andando" nao
|
|
25
|
+
* quer ve-la ali.
|
|
26
|
+
*/
|
|
27
|
+
const ATIVAS: readonly TaskStatus[] = ['in-progress', 'paused', 'in-review'];
|
|
28
|
+
|
|
15
29
|
const contem = (valor: string | undefined, procurado: string): boolean =>
|
|
16
|
-
valor
|
|
30
|
+
valor?.toLowerCase().includes(procurado) ?? false;
|
|
17
31
|
|
|
18
32
|
/**
|
|
19
33
|
* Casa o responsavel por id ou nome, inteiro ou em parte.
|
|
@@ -46,6 +60,8 @@ export function filterTasks(tasks: readonly Task[], criteria: TaskFilterCriteria
|
|
|
46
60
|
return false;
|
|
47
61
|
} else if (criteria.closed && !ENCERRADOS.includes(task.status)) {
|
|
48
62
|
return false;
|
|
63
|
+
} else if (criteria.active && !ATIVAS.includes(task.status)) {
|
|
64
|
+
return false;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
if (criteria.type !== undefined && task.type !== criteria.type) return false;
|
|
@@ -85,7 +101,6 @@ export function summarizeTask(task: Task): TaskSummary {
|
|
|
85
101
|
...(task.assignee && { assignee: { id: task.assignee.id, name: task.assignee.name } }),
|
|
86
102
|
...(task.order !== undefined && { priority: task.order }),
|
|
87
103
|
...(task.groupId && { groupId: task.groupId }),
|
|
88
|
-
...(task.groupName && { groupName: task.groupName }),
|
|
89
104
|
...(task.difficulty !== undefined && { difficulty: task.difficulty }),
|
|
90
105
|
};
|
|
91
106
|
}
|
|
@@ -1,30 +1,13 @@
|
|
|
1
1
|
import type { GroupId, TaskId, TaskStatus, TaskType } from '@opentask/taskin-types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* O que restringe uma listagem de tarefas.
|
|
4
|
+
* O que restringe uma listagem de tarefas — derivado do schema unico.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* @public
|
|
6
|
+
* Vive em `filter-criteria.ts`, onde os criterios sao definidos uma vez so e as
|
|
7
|
+
* superficies (CLI e MCP) derivam. Reexportado aqui para nao mudar os imports
|
|
8
|
+
* existentes.
|
|
10
9
|
*/
|
|
11
|
-
export
|
|
12
|
-
/** Status exato. */
|
|
13
|
-
readonly status?: TaskStatus;
|
|
14
|
-
/** Tipo exato. */
|
|
15
|
-
readonly type?: TaskType;
|
|
16
|
-
/**
|
|
17
|
-
* Responsavel, casado por id ou nome — inteiro ou em parte, sem distinguir
|
|
18
|
-
* maiuscula. Tarefa sem responsavel nunca casa.
|
|
19
|
-
*/
|
|
20
|
-
readonly assignee?: string;
|
|
21
|
-
/** Apenas o que ainda esta em aberto. Ignorado quando `status` e informado. */
|
|
22
|
-
readonly open?: boolean;
|
|
23
|
-
/** Apenas o que foi encerrado. Ignorado quando `status` e informado. */
|
|
24
|
-
readonly closed?: boolean;
|
|
25
|
-
/** Busca livre em id, titulo, status e responsavel. */
|
|
26
|
-
readonly text?: string;
|
|
27
|
-
}
|
|
10
|
+
export type { TaskFilterCriteria } from './filter-criteria.js';
|
|
28
11
|
|
|
29
12
|
/**
|
|
30
13
|
* A forma de uma tarefa numa listagem — o que identifica, nao o que ela diz.
|
|
@@ -39,6 +22,5 @@ export interface TaskSummary {
|
|
|
39
22
|
readonly assignee?: { readonly id: string; readonly name: string };
|
|
40
23
|
readonly priority?: number;
|
|
41
24
|
readonly groupId?: GroupId;
|
|
42
|
-
readonly groupName?: string;
|
|
43
25
|
readonly difficulty?: number;
|
|
44
26
|
}
|
|
@@ -1,2 +1,16 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CriterionCli,
|
|
3
|
+
CriterionSurface,
|
|
4
|
+
FilterCliOption,
|
|
5
|
+
FilterJsonSchema,
|
|
6
|
+
TaskFilterCriteria,
|
|
7
|
+
} from './filter-criteria.js';
|
|
8
|
+
export {
|
|
9
|
+
FILTER_CRITERIA_SURFACES,
|
|
10
|
+
FilterCriteriaSchema,
|
|
11
|
+
filterCriteriaCliOptions,
|
|
12
|
+
filterCriteriaJsonSchema,
|
|
13
|
+
parseFilterCriteria,
|
|
14
|
+
} from './filter-criteria.js';
|
|
1
15
|
export { filterTasks, summarizeTask } from './filter-tasks.js';
|
|
2
|
-
export type {
|
|
16
|
+
export type { TaskSummary } from './filter-tasks.types.js';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { parseGroupId } from '@opentask/taskin-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { IGroupRegistry } from './group-registry.types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Contrato que toda implementacao de {@link IGroupRegistry} precisa cumprir.
|
|
7
|
+
*
|
|
8
|
+
* Vive no pacote agnostico pelo mesmo motivo do contrato de usuarios: um
|
|
9
|
+
* provider de arquivos, um do Redmine e um do GitHub provam o mesmo
|
|
10
|
+
* comportamento sem depender do pacote um do outro. Exportado pelo subcaminho
|
|
11
|
+
* `./testing`, para o `vitest` nunca entrar no grafo de execucao.
|
|
12
|
+
*
|
|
13
|
+
* @param createSubject - Devolve um registro vazio, e uma forma de saber quantas
|
|
14
|
+
* tarefas pertencem a um grupo — porque apagar precisa dizer quantas foram
|
|
15
|
+
* afetadas, e isso e o unico ponto onde o contrato toca em tarefas.
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export function runGroupRegistryContractTests(
|
|
19
|
+
createSubject: () => Promise<{
|
|
20
|
+
registry: IGroupRegistry;
|
|
21
|
+
atribuir: (groupId: string, quantas: number) => Promise<void>;
|
|
22
|
+
}>,
|
|
23
|
+
): void {
|
|
24
|
+
const g = (id: string) => parseGroupId(id);
|
|
25
|
+
|
|
26
|
+
describe('IGroupRegistry contract', () => {
|
|
27
|
+
it('cria e lista', async () => {
|
|
28
|
+
const { registry } = await createSubject();
|
|
29
|
+
|
|
30
|
+
await registry.createGroup({ id: g('g-1'), name: 'Sprint' });
|
|
31
|
+
|
|
32
|
+
expect(await registry.listGroups()).toEqual([{ id: 'g-1', name: 'Sprint' }]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('acha pelo id, e devolve indefinido para o que nao existe', async () => {
|
|
36
|
+
const { registry } = await createSubject();
|
|
37
|
+
await registry.createGroup({ id: g('g-1'), name: 'Sprint' });
|
|
38
|
+
|
|
39
|
+
expect(await registry.findGroup(g('g-1'))).toEqual({ id: 'g-1', name: 'Sprint' });
|
|
40
|
+
expect(await registry.findGroup(g('g-2'))).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('recusa id repetido', async () => {
|
|
44
|
+
const { registry } = await createSubject();
|
|
45
|
+
await registry.createGroup({ id: g('g-1'), name: 'Sprint' });
|
|
46
|
+
|
|
47
|
+
await expect(registry.createGroup({ id: g('g-1'), name: 'Outro' })).rejects.toThrow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* O ponto da entidade: o nome vive num lugar so, entao renomear e uma
|
|
52
|
+
* escrita — e nenhuma tarefa e tocada.
|
|
53
|
+
*/
|
|
54
|
+
it('renomeia sem tocar em tarefa nenhuma', async () => {
|
|
55
|
+
const { registry, atribuir } = await createSubject();
|
|
56
|
+
await registry.createGroup({ id: g('g-1'), name: 'Sprint' });
|
|
57
|
+
await atribuir('g-1', 3);
|
|
58
|
+
|
|
59
|
+
await registry.renameGroup(g('g-1'), 'Sprint de outubro');
|
|
60
|
+
|
|
61
|
+
expect(await registry.findGroup(g('g-1'))).toEqual({ id: 'g-1', name: 'Sprint de outubro' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('recusa renomear o que nao existe', async () => {
|
|
65
|
+
const { registry } = await createSubject();
|
|
66
|
+
|
|
67
|
+
await expect(registry.renameGroup(g('g-404'), 'Qualquer')).rejects.toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('apagar sem destino solta os membros, e diz quantos eram', async () => {
|
|
71
|
+
const { registry, atribuir } = await createSubject();
|
|
72
|
+
await registry.createGroup({ id: g('g-1'), name: 'Sprint' });
|
|
73
|
+
await atribuir('g-1', 3);
|
|
74
|
+
|
|
75
|
+
const resultado = await registry.deleteGroup(g('g-1'));
|
|
76
|
+
|
|
77
|
+
expect(resultado.reassigned).toBe(3);
|
|
78
|
+
expect(await registry.findGroup(g('g-1'))).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('apagar com destino move os membros para la', async () => {
|
|
82
|
+
const { registry, atribuir } = await createSubject();
|
|
83
|
+
await registry.createGroup({ id: g('g-1'), name: 'Sprint' });
|
|
84
|
+
await registry.createGroup({ id: g('g-2'), name: 'Backlog' });
|
|
85
|
+
await atribuir('g-1', 2);
|
|
86
|
+
|
|
87
|
+
const resultado = await registry.deleteGroup(g('g-1'), { reassignTo: g('g-2') });
|
|
88
|
+
|
|
89
|
+
expect(resultado.reassigned).toBe(2);
|
|
90
|
+
expect(await registry.findGroup(g('g-2'))).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('recusa apagar o que nao existe', async () => {
|
|
94
|
+
const { registry } = await createSubject();
|
|
95
|
+
|
|
96
|
+
await expect(registry.deleteGroup(g('g-404'))).rejects.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Group, GroupId } from '@opentask/taskin-types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* O que acontece com os membros quando o grupo e apagado.
|
|
5
|
+
*
|
|
6
|
+
* Nao ha opcao de "nao dizer": tarefa apontando para grupo inexistente em
|
|
7
|
+
* silencio foi o unico resultado considerado inaceitavel ao decidir isto. As
|
|
8
|
+
* duas escolhas cobrem o que Redmine (`reassign_to_id`) e Jira (`moveIssuesTo`)
|
|
9
|
+
* ja oferecem na propria chamada de exclusao.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export interface DeleteGroupOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Para onde os membros vao. Ausente, eles ficam **sem grupo** — e a operacao
|
|
16
|
+
* informa quantos foram afetados, para o efeito nunca ser invisivel.
|
|
17
|
+
*/
|
|
18
|
+
reassignTo?: GroupId;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @public */
|
|
22
|
+
export interface DeleteGroupResult {
|
|
23
|
+
/** Quantas tarefas pertenciam ao grupo apagado. */
|
|
24
|
+
reassigned: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Operacoes de grupo que um provider oferece.
|
|
29
|
+
*
|
|
30
|
+
* Separado de `ITaskProvider` de proposito: nem toda fonte tem o conceito. Um
|
|
31
|
+
* provider que tenha implementa isto; um que nao tenha simplesmente nao o
|
|
32
|
+
* expoe, e quem consome descobre pela ausencia em vez de receber uma operacao
|
|
33
|
+
* que falha — o mesmo erro do `-t sse` do `mcp-server`, que existia na flag e
|
|
34
|
+
* nao na implementacao.
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
export interface IGroupRegistry {
|
|
39
|
+
listGroups: () => Promise<Group[]>;
|
|
40
|
+
findGroup: (id: GroupId) => Promise<Group | undefined>;
|
|
41
|
+
/** Falha quando o id ja existe. */
|
|
42
|
+
createGroup: (group: Group) => Promise<void>;
|
|
43
|
+
/** Falha quando o grupo nao existe. */
|
|
44
|
+
renameGroup: (id: GroupId, name: string) => Promise<void>;
|
|
45
|
+
/** Falha quando o grupo nao existe. */
|
|
46
|
+
deleteGroup: (id: GroupId, options?: DeleteGroupOptions) => Promise<DeleteGroupResult>;
|
|
47
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Re-export commonly used types from @opentask/taskin-types for convenience
|
|
2
2
|
export type { Task, TaskStatus, TaskType, User } from '@opentask/taskin-types';
|
|
3
3
|
export * from './filter-tasks/index';
|
|
4
|
+
export * from './group-registry.types';
|
|
4
5
|
export * from './metrics.types';
|
|
6
|
+
export * from './numerar-prioridade/index';
|
|
7
|
+
export * from './ordenar-tarefas/index';
|
|
5
8
|
export * from './task-manager';
|
|
6
9
|
export * from './task-manager.types';
|
|
7
10
|
export * from './user-registry.types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './numerar-prioridade.js';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Task } from '@opentask/taskin-types';
|
|
2
|
+
import { parseTaskId } from '@opentask/taskin-types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { numerarPrioridade } from './numerar-prioridade.js';
|
|
5
|
+
|
|
6
|
+
const tarefa = (id: string, order?: number): Task =>
|
|
7
|
+
({ id: parseTaskId(id), title: `Tarefa ${id}`, status: 'pending', type: 'feat', order }) as Task;
|
|
8
|
+
|
|
9
|
+
const ids = (ts: readonly Task[]) => ts.map((t) => String(t.id));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Numeracao inicial de prioridade.
|
|
13
|
+
*
|
|
14
|
+
* Existe para eliminar o estado que custa caro: um projeto **meio numerado**.
|
|
15
|
+
* Enquanto metade das tarefas nao tem `order`, dar numero a uma do meio obriga a
|
|
16
|
+
* numerar todos os antecessores — 124 arquivos num projeto de 500, medido. Com o
|
|
17
|
+
* projeto inteiro numerado de uma vez, num ato deliberado, todo movimento
|
|
18
|
+
* seguinte custa um arquivo.
|
|
19
|
+
*
|
|
20
|
+
* A funcao devolve **so** o que precisa mudar, e nao a lista inteira: quem chama
|
|
21
|
+
* grava exatamente isso.
|
|
22
|
+
*/
|
|
23
|
+
describe('numerarPrioridade', () => {
|
|
24
|
+
it('num projeto sem nenhuma prioridade, numera todas preservando a ordem', () => {
|
|
25
|
+
const tarefas = [tarefa('001'), tarefa('002'), tarefa('003')];
|
|
26
|
+
|
|
27
|
+
const mudancas = numerarPrioridade(tarefas);
|
|
28
|
+
|
|
29
|
+
expect(ids(mudancas)).toEqual(['001', '002', '003']);
|
|
30
|
+
expect(mudancas.map((t) => t.order)).toEqual([100, 200, 300]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
* Quem ja priorizou tomou uma decisao, e o comando preenche as lacunas em
|
|
35
|
+
* volta dela em vez de reescreve-la.
|
|
36
|
+
*/
|
|
37
|
+
it('mantem o numero de quem ja tem, e so preenche as lacunas', () => {
|
|
38
|
+
const tarefas = [tarefa('001', 50), tarefa('002'), tarefa('003', 900)];
|
|
39
|
+
|
|
40
|
+
const mudancas = numerarPrioridade(tarefas);
|
|
41
|
+
|
|
42
|
+
expect(ids(mudancas)).toEqual(['002']);
|
|
43
|
+
const nova = mudancas[0]?.order ?? 0;
|
|
44
|
+
expect(nova).toBeGreaterThan(50);
|
|
45
|
+
expect(nova).toBeLessThan(900);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Seguro de pôr num script ou num hook: a segunda passada nao escreve nada.
|
|
50
|
+
*/
|
|
51
|
+
it('rodar de novo nao muda nada', () => {
|
|
52
|
+
const tarefas = [tarefa('001'), tarefa('002'), tarefa('003')];
|
|
53
|
+
|
|
54
|
+
const primeira = numerarPrioridade(tarefas);
|
|
55
|
+
const segunda = numerarPrioridade(primeira.length ? aplicar(tarefas, primeira) : tarefas);
|
|
56
|
+
|
|
57
|
+
expect(segunda).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('nao devolve nada quando todas ja estao numeradas e em ordem', () => {
|
|
61
|
+
expect(numerarPrioridade([tarefa('001', 100), tarefa('002', 200)])).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Aplica as mudancas devolvidas, como quem grava faria. */
|
|
66
|
+
function aplicar(tarefas: readonly Task[], mudancas: readonly Task[]): Task[] {
|
|
67
|
+
const por = new Map(mudancas.map((t) => [String(t.id), t]));
|
|
68
|
+
return tarefas.map((t) => por.get(String(t.id)) ?? t);
|
|
69
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Task } from '@opentask/taskin-types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Passo largo de proposito.
|
|
5
|
+
*
|
|
6
|
+
* Com 100 cabem muitos movimentos entre dois vizinhos antes de a renumeracao
|
|
7
|
+
* local precisar acontecer. Com 10 — o passo que o quadro de priorizacao usava —
|
|
8
|
+
* o espaco acaba depressa, e cada esgotamento custa escrita em arquivo.
|
|
9
|
+
*/
|
|
10
|
+
export const PASSO_DE_PRIORIDADE = 100;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Devolve as tarefas que precisam de um `order` novo, e so elas.
|
|
14
|
+
*
|
|
15
|
+
* ## Por que isto existe
|
|
16
|
+
*
|
|
17
|
+
* O custo de reordenar no quadro de priorizacao ja e de um arquivo por
|
|
18
|
+
* movimento — **exceto** num projeto meio numerado. Ali, dar numero a uma tarefa
|
|
19
|
+
* do meio obriga a numerar todos os antecessores, porque uma tarefa sem `order`
|
|
20
|
+
* ordena por ultimo e nao tem posicao propria entre as numeradas. Num projeto de
|
|
21
|
+
* 500 tarefas com metade sem numero, isso chegou a 124 arquivos.
|
|
22
|
+
*
|
|
23
|
+
* A saida nao e um algoritmo mais esperto: e acabar com o estado meio numerado.
|
|
24
|
+
* Depois desta passada, ou o projeto inteiro tem numero, ou nunca foi priorizado
|
|
25
|
+
* — e todo movimento seguinte custa um arquivo.
|
|
26
|
+
*
|
|
27
|
+
* ## O que ela preserva
|
|
28
|
+
*
|
|
29
|
+
* A ordem relativa em que as tarefas chegaram, e o numero de quem ja tem um.
|
|
30
|
+
* Quem priorizou tomou uma decisao; o comando preenche as lacunas em volta dela
|
|
31
|
+
* em vez de reescreve-la.
|
|
32
|
+
*
|
|
33
|
+
* @param tarefas - Na ordem em que devem ficar
|
|
34
|
+
* @returns Copias apenas das tarefas que mudaram, prontas para gravar. Vazio
|
|
35
|
+
* quando nao ha nada a fazer — o que torna uma segunda passada inofensiva.
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
export function numerarPrioridade(tarefas: readonly Task[], passo = PASSO_DE_PRIORIDADE): Task[] {
|
|
39
|
+
const mudancas: Task[] = [];
|
|
40
|
+
let anterior = 0;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < tarefas.length; i++) {
|
|
43
|
+
const atual = tarefas[i];
|
|
44
|
+
if (!atual) continue;
|
|
45
|
+
|
|
46
|
+
if (atual.order !== undefined && atual.order > anterior) {
|
|
47
|
+
anterior = atual.order;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* Precisa de numero. O teto e o proximo valor ja existente que ainda esta a
|
|
53
|
+
* frente — respeita-lo e o que preserva a decisao de quem priorizou.
|
|
54
|
+
*/
|
|
55
|
+
const teto = tarefas.slice(i + 1).find((t) => t?.order !== undefined && t.order > anterior)?.order;
|
|
56
|
+
const valor = teto === undefined ? anterior + passo : Math.floor((anterior + teto) / 2);
|
|
57
|
+
|
|
58
|
+
if (!(valor > anterior) || (teto !== undefined && valor >= teto)) {
|
|
59
|
+
/*
|
|
60
|
+
* Sem espaco entre os vizinhos: abre espaco andando para a frente e para
|
|
61
|
+
* no primeiro que ja estiver alem. A renumeracao fica na vizinhanca.
|
|
62
|
+
*/
|
|
63
|
+
let corrido = anterior;
|
|
64
|
+
for (let j = i; j < tarefas.length; j++) {
|
|
65
|
+
const item = tarefas[j];
|
|
66
|
+
if (!item) continue;
|
|
67
|
+
if (j > i && item.order !== undefined && item.order > corrido) break;
|
|
68
|
+
corrido += passo;
|
|
69
|
+
mudancas.push({ ...item, order: corrido });
|
|
70
|
+
}
|
|
71
|
+
anterior = corrido;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
mudancas.push({ ...atual, order: valor });
|
|
76
|
+
anterior = valor;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return mudancas;
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ordenar-tarefas.js';
|