redmine-context 1.0.0 → 1.1.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.
Files changed (40) hide show
  1. package/README.md +20 -5
  2. package/dist/bundle/journal-detail.d.ts +89 -0
  3. package/dist/bundle/journal-detail.js +240 -0
  4. package/dist/bundle/markdown.d.ts +7 -0
  5. package/dist/bundle/markdown.js +21 -12
  6. package/dist/client/enumerations.d.ts +39 -0
  7. package/dist/client/enumerations.js +76 -0
  8. package/dist/client/index.d.ts +1 -0
  9. package/dist/client/index.js +1 -0
  10. package/dist/fetch-issue-bundle.js +17 -2
  11. package/dist/fetch-last-issues.d.ts +96 -0
  12. package/dist/fetch-last-issues.js +120 -0
  13. package/dist/index.d.ts +5 -1
  14. package/dist/index.js +22 -2
  15. package/dist/normalize/issue.js +32 -3
  16. package/dist/surfaces/cli/commands.d.ts +17 -0
  17. package/dist/surfaces/cli/commands.js +105 -12
  18. package/dist/surfaces/cli/main.js +22 -3
  19. package/dist/surfaces/mcp/server.d.ts +22 -45
  20. package/dist/surfaces/mcp/server.js +67 -56
  21. package/dist/surfaces/mcp/tools.d.ts +109 -0
  22. package/dist/surfaces/mcp/tools.js +93 -0
  23. package/dist/surfaces/tui/app.d.ts +14 -0
  24. package/dist/surfaces/tui/app.js +32 -1
  25. package/dist/surfaces/tui/banner.d.ts +43 -0
  26. package/dist/surfaces/tui/banner.js +93 -0
  27. package/dist/surfaces/tui/components/gauge.d.ts +27 -0
  28. package/dist/surfaces/tui/components/gauge.js +53 -0
  29. package/dist/surfaces/tui/components/gradient-banner.d.ts +13 -0
  30. package/dist/surfaces/tui/components/gradient-banner.js +41 -0
  31. package/dist/surfaces/tui/glyphs.d.ts +4 -0
  32. package/dist/surfaces/tui/glyphs.js +4 -0
  33. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +7 -1
  34. package/dist/surfaces/tui/hooks/use-issue-detail.js +15 -2
  35. package/dist/surfaces/tui/screens/issue-detail.js +28 -7
  36. package/dist/surfaces/tui/screens/jobs.js +3 -2
  37. package/dist/surfaces/tui/screens/welcome.js +9 -1
  38. package/dist/surfaces/tui/wrap.d.ts +38 -0
  39. package/dist/surfaces/tui/wrap.js +82 -0
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  Consumidor de Redmine que entrega contexto completo de issues — texto e mídia (áudio/vídeo/imagem) extraída **100% localmente** — para qualquer LLM, via MCP server, CLI e TUI.
4
4
 
5
+ ![Demo: um print anexado a uma issue vira contexto pesquisável, sem nada sair da máquina](demo/redmine-context.gif)
6
+
7
+ O print de erro anexado na issue é opaco para qualquer ferramenta. Com `--extract`,
8
+ o `tesseract` roda **na sua máquina** e o stack trace passa a fazer parte do
9
+ contexto — o mesmo bundle que o MCP entrega ao seu LLM. Nada é enviado para
10
+ lugar nenhum. O roteiro dessa gravação é reprodutível: [`demo/demo.tape`](demo/demo.tape).
11
+
5
12
  > Planejamento: `documentation/development/PLAN.md` · Backlog: `documentation/development/BACKLOG.md` · Decisões: `documentation/adr/`
6
13
 
7
14
  ## Requisitos
@@ -142,9 +149,14 @@ redmine-context issue 42 # usa a instância salva no login (ou --url/
142
149
  # --extract liga o OCR dos anexos de imagem e embute o texto no bundle
143
150
  # (requer tesseract; ver Extração de mídia abaixo).
144
151
 
152
+ # 2b) last — quando você NÃO sabe o id: imprime o bundle da issue mais recente.
153
+ # --order updated (padrão) | created | priority --count <n> (padrão 1, teto 5)
154
+ redmine-context last
155
+ redmine-context last --order priority --count 3
156
+
145
157
  # 3) mcp add — registra o MCP server no seu cliente (ex.: Claude) para expor as
146
- # tools read-only get_issue_context, search_issues e get_attachment_text,
147
- # usando a mesma credencial da cascata.
158
+ # tools read-only get_issue_context, search_issues, get_attachment_text e
159
+ # get_last, usando a mesma credencial da cascata.
148
160
  claude mcp add redmine-context \
149
161
  --env REDMINE_URL=https://redmine.example \
150
162
  -- npx -y redmine-context mcp
@@ -188,11 +200,12 @@ Detalhes e passos manuais (adicionar `NPM_TOKEN`, tornar o repo público, dispar
188
200
 
189
201
  ## MCP server (stdio)
190
202
 
191
- O subcomando `redmine-context mcp` sobe um servidor [MCP](https://modelcontextprotocol.io) sobre stdio, expondo três tools read-only:
203
+ O subcomando `redmine-context mcp` sobe um servidor [MCP](https://modelcontextprotocol.io) sobre stdio, expondo quatro tools read-only:
192
204
 
193
205
  - `get_issue_context(issue_id: number, format?: 'markdown' | 'json', extract_attachments?: boolean)` — busca a issue na instância configurada, normaliza e retorna o bundle (Markdown por padrão). Com `extract_attachments: true`, embute o texto (OCR) dos anexos de imagem no bundle (default `false`, pois adiciona latência de download+OCR).
194
206
  - `search_issues(query?, project_id?, status_id?, assigned_to_id?, updated_on?, limit?)` — busca issues por filtros estruturados e, opcionalmente, texto livre (`query`, best-effort via `/search`); retorna uma lista compacta paginada.
195
207
  - `get_attachment_text(issue_id: number, attachment_id: number)` — retorna o texto extraído (OCR, com cache) de um anexo, dentro de uma fence de conteúdo não confiável. Anexo não processável retorna o status/motivo legível (`skipped`/`unsupported`/`failed`), nunca um erro genérico.
208
+ - `get_last(order?, count?, format?, extract_attachments?)` — retorna o **bundle completo** das issues mais recentes, sem exigir o id. `order` é `updated` (padrão, mexida mais recente), `created` (entrada mais recente) ou `priority` (mais urgente, desempatando pela mais recente); `count` vai até 5 (padrão 1). Considera apenas issues **abertas** — para filtrar por projeto/status/responsável, use `search_issues`. Em `format: 'json'` a saída é **sempre um array**, pois a tool devolve uma coleção.
196
209
 
197
210
  A instância vem sempre da configuração do processo (`REDMINE_URL` + cascata de credencial, `REDMINE_API_KEY` no modo headless): **nenhuma tool aceita URL/host arbitrário**. Erros 403/404 e credencial ausente retornam um erro MCP claro (`isError`). O stdout é reservado ao protocolo; logs vão para stderr.
198
211
 
@@ -213,8 +226,8 @@ Passe a instância via `--env` (aplicado ao ambiente do servidor), por exemplo
213
226
  `claude mcp add redmine-context --env REDMINE_URL=https://redmine.example -- npx -y redmine-context mcp`.
214
227
  Configure o ambiente do servidor com `REDMINE_URL` e `REDMINE_API_KEY` (ou rode
215
228
  `redmine-context login` para gravar a credencial na cascata). Com a integração
216
- ativa, o cliente ganha as tools read-only `get_issue_context`, `search_issues` e
217
- `get_attachment_text` (detalhadas acima).
229
+ ativa, o cliente ganha as tools read-only `get_issue_context`, `search_issues`,
230
+ `get_attachment_text` e `get_last` (detalhadas acima).
218
231
 
219
232
  ## Extração de mídia (OCR)
220
233
 
@@ -254,6 +267,8 @@ redmine-context doctor # exit 0 se tudo presente, 1 se faltar algum
254
267
 
255
268
  ## TUI interativa
256
269
 
270
+ ![A TUI: banner com o gradiente da paleta ativa e a tela de Aparência trocando entre as 12 paletas](demo/tui.gif)
271
+
257
272
  `redmine-context` **sem argumentos** (num terminal interativo) abre a interface
258
273
  de texto completa — mesma credencial e mesmo core da CLI/MCP:
259
274
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Semântica dos `journal.details` do Redmine — rótulos legíveis e ids resolvidos.
3
+ *
4
+ * A API grava o nome CRU da coluna (`status_id`, `done_ratio`) e os valores como
5
+ * ids, então `status_id: 12 → 7` não informa nada a quem lê — nem humano nem LLM.
6
+ * Este módulo traduz isso UMA vez, para TODAS as superfícies: o bundle Markdown
7
+ * (`./markdown.ts`) e a tela de detalhe da TUI consumiam a mesma informação e
8
+ * divergiam, com a TUI mostrando os ids crus.
9
+ *
10
+ * Cada parte devolvida diz se é texto NOSSO (`trusted`) ou conteúdo derivado do
11
+ * Redmine (`trusted: false`). Quem renderiza decide a política: o bundle envolve
12
+ * o não confiável em `<untrusted-content>` (anti prompt-injection), a TUI apenas
13
+ * exibe. A distinção é o motivo de este módulo devolver partes em vez de strings
14
+ * já formatadas.
15
+ *
16
+ * Nada sai como valor CRU sem ser um id inteiro (ver `idToken`): `detail.name` e
17
+ * `old/new_value` são strings livres da API, e um valor forjado escaparia da
18
+ * fence do bundle.
19
+ */
20
+ import type { Issue, JournalDetail } from '../contract.js';
21
+ /**
22
+ * Dicionários `id → nome` para resolver os ids HISTÓRICOS de um journal.
23
+ *
24
+ * Sem eles só o valor que coincide com o estado atual da issue ganha nome, e o
25
+ * outro lado da alteração fica como `#id` — ilegível ("não sei o que é status
26
+ * 12"). Todos os campos são opcionais: o que faltar degrada para `#id`.
27
+ */
28
+ export interface DetailLookups {
29
+ /** `id → nome` de status (`/issue_statuses.json`). */
30
+ readonly status?: ReadonlyMap<number, string> | undefined;
31
+ /** `id → nome` de trackers (`/trackers.json`). */
32
+ readonly tracker?: ReadonlyMap<number, string> | undefined;
33
+ /** `id → nome` de prioridades (`/enumerations/issue_priorities.json`). */
34
+ readonly priority?: ReadonlyMap<number, string> | undefined;
35
+ /** `id → nome` de usuários (ver {@link collectUsers}). */
36
+ readonly user?: ReadonlyMap<number, string> | undefined;
37
+ }
38
+ /**
39
+ * Monta o dicionário de usuários a partir da PRÓPRIA issue — sem rede.
40
+ *
41
+ * `/users.json` exige admin na maioria das instâncias, mas os nomes já circulam
42
+ * no payload: autor, responsável, autor de cada journal, autor de cada anexo e
43
+ * watchers. Isso costuma cobrir os ids que aparecem no histórico, já que quem
44
+ * mexeu na issue quase sempre deixou um journal.
45
+ *
46
+ * @param issue - Issue normalizada.
47
+ * @returns Mapa `id → nome` com todos os usuários citados no payload.
48
+ */
49
+ export declare function collectUsers(issue: Issue): ReadonlyMap<number, string>;
50
+ /**
51
+ * Um pedaço de texto pronto para exibir, com a marca de confiança.
52
+ */
53
+ export interface DetailPart {
54
+ /** Texto a exibir. */
55
+ text: string;
56
+ /**
57
+ * `true` quando o texto é NOSSO (rótulo traduzido, `#id`, `40%`) e dispensa
58
+ * marcação de conteúdo não confiável; `false` quando vem do Redmine.
59
+ */
60
+ trusted: boolean;
61
+ }
62
+ /**
63
+ * Rótulo de um detail de journal.
64
+ *
65
+ * @param detail - Detalhe do journal.
66
+ * @param issue - Issue normalizada (resolve nomes de custom field).
67
+ * @returns O rótulo e sua marca de confiança.
68
+ * @example
69
+ * journalDetailLabel({ property: 'attr', name: 'status_id' }, issue)
70
+ * // { text: 'Status', trusted: true }
71
+ */
72
+ export declare function journalDetailLabel(detail: JournalDetail, issue: Issue): DetailPart;
73
+ /**
74
+ * Um lado (antes/depois) de uma alteração.
75
+ *
76
+ * Ids de referência recebem `#` para nunca parecerem número solto. O nome vem,
77
+ * em ordem: do dicionário da instância (`lookups`, que resolve qualquer id,
78
+ * inclusive os históricos) ou do estado ATUAL da issue, quando o id coincide. A comparação é segura:
79
+ * só o último journal que alterou um campo tem `new_value` igual ao valor
80
+ * corrente, então nenhum valor histórico é nomeado incorretamente.
81
+ *
82
+ * @param detail - Detalhe do journal (define como o valor é interpretado).
83
+ * @param raw - Valor bruto (`old_value` ou `new_value`).
84
+ * @param issue - Issue normalizada (resolve refs pelo estado atual).
85
+ * @param lookups - Dicionários da instância; ausentes ⇒ degrada para `#id`.
86
+ * @returns A parte a exibir, ou `undefined` para ausência (cada superfície usa
87
+ * seu próprio placeholder).
88
+ */
89
+ export declare function journalDetailValue(detail: JournalDetail, raw: string | null | undefined, issue: Issue, lookups?: DetailLookups): DetailPart | undefined;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Semântica dos `journal.details` do Redmine — rótulos legíveis e ids resolvidos.
3
+ *
4
+ * A API grava o nome CRU da coluna (`status_id`, `done_ratio`) e os valores como
5
+ * ids, então `status_id: 12 → 7` não informa nada a quem lê — nem humano nem LLM.
6
+ * Este módulo traduz isso UMA vez, para TODAS as superfícies: o bundle Markdown
7
+ * (`./markdown.ts`) e a tela de detalhe da TUI consumiam a mesma informação e
8
+ * divergiam, com a TUI mostrando os ids crus.
9
+ *
10
+ * Cada parte devolvida diz se é texto NOSSO (`trusted`) ou conteúdo derivado do
11
+ * Redmine (`trusted: false`). Quem renderiza decide a política: o bundle envolve
12
+ * o não confiável em `<untrusted-content>` (anti prompt-injection), a TUI apenas
13
+ * exibe. A distinção é o motivo de este módulo devolver partes em vez de strings
14
+ * já formatadas.
15
+ *
16
+ * Nada sai como valor CRU sem ser um id inteiro (ver `idToken`): `detail.name` e
17
+ * `old/new_value` são strings livres da API, e um valor forjado escaparia da
18
+ * fence do bundle.
19
+ */
20
+ /**
21
+ * Monta o dicionário de usuários a partir da PRÓPRIA issue — sem rede.
22
+ *
23
+ * `/users.json` exige admin na maioria das instâncias, mas os nomes já circulam
24
+ * no payload: autor, responsável, autor de cada journal, autor de cada anexo e
25
+ * watchers. Isso costuma cobrir os ids que aparecem no histórico, já que quem
26
+ * mexeu na issue quase sempre deixou um journal.
27
+ *
28
+ * @param issue - Issue normalizada.
29
+ * @returns Mapa `id → nome` com todos os usuários citados no payload.
30
+ */
31
+ export function collectUsers(issue) {
32
+ const users = new Map();
33
+ const add = (ref) => {
34
+ if (ref !== undefined && ref.id !== 0 && ref.name.length > 0)
35
+ users.set(ref.id, ref.name);
36
+ };
37
+ add(issue.author);
38
+ add(issue.assigned_to);
39
+ for (const journal of issue.journals)
40
+ add(journal.user);
41
+ for (const attachment of issue.attachments)
42
+ add(attachment.author);
43
+ for (const watcher of issue.watchers ?? [])
44
+ add(watcher);
45
+ return users;
46
+ }
47
+ /** Dicionário da enumeração correspondente a um atributo. */
48
+ function lookupFor(attr, lookups) {
49
+ if (lookups === undefined)
50
+ return undefined;
51
+ switch (attr) {
52
+ case 'status_id':
53
+ return lookups.status;
54
+ case 'tracker_id':
55
+ return lookups.tracker;
56
+ case 'priority_id':
57
+ return lookups.priority;
58
+ case 'assigned_to_id':
59
+ case 'author_id':
60
+ return lookups.user;
61
+ default:
62
+ return undefined;
63
+ }
64
+ }
65
+ /**
66
+ * Rótulos legíveis dos atributos padrão do Redmine.
67
+ *
68
+ * Vocabulário fixo da ferramenta (não conteúdo da instância), portanto texto
69
+ * confiável.
70
+ */
71
+ const ATTR_LABELS = {
72
+ subject: 'Assunto',
73
+ description: 'Descrição',
74
+ project_id: 'Projeto',
75
+ tracker_id: 'Tracker',
76
+ status_id: 'Status',
77
+ priority_id: 'Prioridade',
78
+ author_id: 'Autor',
79
+ assigned_to_id: 'Responsável',
80
+ category_id: 'Categoria',
81
+ fixed_version_id: 'Versão',
82
+ parent_id: 'Issue pai',
83
+ child_id: 'Sub-issue',
84
+ done_ratio: 'Progresso',
85
+ start_date: 'Início',
86
+ due_date: 'Prazo',
87
+ estimated_hours: 'Estimativa',
88
+ is_private: 'Privada',
89
+ };
90
+ /** Atributos cujo valor é o número de OUTRA issue (não um id de enumeração). */
91
+ const ISSUE_REF_ATTRS = new Set(['parent_id', 'child_id']);
92
+ /**
93
+ * Tipos de relação do Redmine — vocabulário FECHADO, portanto confiável. Um
94
+ * valor fora desta lista é tratado como conteúdo derivado.
95
+ */
96
+ const RELATION_TYPES = new Set([
97
+ 'relates',
98
+ 'duplicates',
99
+ 'duplicated',
100
+ 'blocks',
101
+ 'blocked',
102
+ 'precedes',
103
+ 'follows',
104
+ 'copied_to',
105
+ 'copied_from',
106
+ ]);
107
+ /**
108
+ * Porteiro dos valores exibidos sem marcação.
109
+ *
110
+ * @param raw - Valor bruto do detail.
111
+ * @returns O próprio valor se for um inteiro não negativo; senão `undefined`.
112
+ */
113
+ function idToken(raw) {
114
+ return /^\d+$/.test(raw) ? raw : undefined;
115
+ }
116
+ /**
117
+ * Ref ATUAL da issue correspondente a um atributo, quando o contrato a carrega.
118
+ *
119
+ * Usada só para NOMEAR um id que coincide com o estado corrente; nunca para
120
+ * inferir estado histórico.
121
+ *
122
+ * @param issue - Issue normalizada.
123
+ * @param attr - Nome cru do atributo (ex.: `status_id`).
124
+ * @returns A ref atual, ou `undefined` se o atributo não tiver uma no contrato.
125
+ */
126
+ function currentRefFor(issue, attr) {
127
+ switch (attr) {
128
+ case 'project_id':
129
+ return issue.project;
130
+ case 'tracker_id':
131
+ return issue.tracker;
132
+ case 'status_id':
133
+ return issue.status;
134
+ case 'priority_id':
135
+ return issue.priority;
136
+ case 'author_id':
137
+ return issue.author;
138
+ case 'assigned_to_id':
139
+ return issue.assigned_to;
140
+ default:
141
+ return undefined;
142
+ }
143
+ }
144
+ /**
145
+ * Nome do custom field a partir do id: num detail `cf`, o `name` é o ID do campo
146
+ * (não o rótulo), e sai como número solto sem esta resolução.
147
+ *
148
+ * @param issue - Issue normalizada (fonte dos `custom_fields`).
149
+ * @param rawId - `detail.name` de um detail `cf`.
150
+ * @returns O nome do campo, ou `undefined` se o id não constar na issue.
151
+ */
152
+ function customFieldName(issue, rawId) {
153
+ const id = Number(rawId);
154
+ if (!Number.isInteger(id))
155
+ return undefined;
156
+ return issue.custom_fields.find((field) => field.id === id)?.name;
157
+ }
158
+ /**
159
+ * Rótulo de um detail de journal.
160
+ *
161
+ * @param detail - Detalhe do journal.
162
+ * @param issue - Issue normalizada (resolve nomes de custom field).
163
+ * @returns O rótulo e sua marca de confiança.
164
+ * @example
165
+ * journalDetailLabel({ property: 'attr', name: 'status_id' }, issue)
166
+ * // { text: 'Status', trusted: true }
167
+ */
168
+ export function journalDetailLabel(detail, issue) {
169
+ if (detail.property === 'cf') {
170
+ const name = customFieldName(issue, detail.name);
171
+ // Nome definido pelo admin da instância: conteúdo derivado.
172
+ if (name !== undefined)
173
+ return { text: name, trusted: false };
174
+ return { text: `campo #${detail.name}`, trusted: idToken(detail.name) !== undefined };
175
+ }
176
+ if (detail.property === 'attachment') {
177
+ const id = idToken(detail.name);
178
+ return id === undefined
179
+ ? { text: `Anexo ${detail.name}`, trusted: false }
180
+ : { text: `Anexo #${id}`, trusted: true };
181
+ }
182
+ if (detail.property === 'relation') {
183
+ return RELATION_TYPES.has(detail.name)
184
+ ? { text: `Relação (${detail.name})`, trusted: true }
185
+ : { text: detail.name, trusted: false };
186
+ }
187
+ const label = ATTR_LABELS[detail.name];
188
+ return label === undefined ? { text: detail.name, trusted: false } : { text: label, trusted: true };
189
+ }
190
+ /**
191
+ * Um lado (antes/depois) de uma alteração.
192
+ *
193
+ * Ids de referência recebem `#` para nunca parecerem número solto. O nome vem,
194
+ * em ordem: do dicionário da instância (`lookups`, que resolve qualquer id,
195
+ * inclusive os históricos) ou do estado ATUAL da issue, quando o id coincide. A comparação é segura:
196
+ * só o último journal que alterou um campo tem `new_value` igual ao valor
197
+ * corrente, então nenhum valor histórico é nomeado incorretamente.
198
+ *
199
+ * @param detail - Detalhe do journal (define como o valor é interpretado).
200
+ * @param raw - Valor bruto (`old_value` ou `new_value`).
201
+ * @param issue - Issue normalizada (resolve refs pelo estado atual).
202
+ * @param lookups - Dicionários da instância; ausentes ⇒ degrada para `#id`.
203
+ * @returns A parte a exibir, ou `undefined` para ausência (cada superfície usa
204
+ * seu próprio placeholder).
205
+ */
206
+ export function journalDetailValue(detail, raw, issue, lookups) {
207
+ if (raw === null || raw === undefined || raw === '')
208
+ return undefined;
209
+ const id = idToken(raw);
210
+ // Um detail `relation` registra a issue do outro lado da relação.
211
+ if (detail.property === 'relation') {
212
+ return id === undefined ? { text: raw, trusted: false } : { text: `issue #${id}`, trusted: true };
213
+ }
214
+ if (detail.property !== 'attr')
215
+ return { text: raw, trusted: false };
216
+ if (detail.name === 'done_ratio') {
217
+ return id === undefined ? { text: raw, trusted: false } : { text: `${id}%`, trusted: true };
218
+ }
219
+ if (ISSUE_REF_ATTRS.has(detail.name)) {
220
+ return id === undefined ? { text: raw, trusted: false } : { text: `issue #${id}`, trusted: true };
221
+ }
222
+ // Dicionário da instância primeiro: resolve QUALQUER id, inclusive o valor
223
+ // histórico que não corresponde mais ao estado atual.
224
+ if (id !== undefined) {
225
+ const named = lookupFor(detail.name, lookups)?.get(Number(id));
226
+ if (named !== undefined)
227
+ return { text: `${named} (#${id})`, trusted: true };
228
+ }
229
+ const ref = currentRefFor(issue, detail.name);
230
+ if (id !== undefined && ref !== undefined && ref.id !== 0 && String(ref.id) === id) {
231
+ // O NOME vem do Redmine, mas é a mesma categoria dos metadados estruturais
232
+ // (status/prioridade/responsável) já exibidos sem marcação nas duas
233
+ // superfícies — mantém a política existente em vez de criar uma nova.
234
+ return { text: `${ref.name} (#${id})`, trusted: true };
235
+ }
236
+ if (id !== undefined && ATTR_LABELS[detail.name] !== undefined && detail.name.endsWith('_id')) {
237
+ return { text: `#${id}`, trusted: true };
238
+ }
239
+ return { text: raw, trusted: false };
240
+ }
@@ -21,6 +21,7 @@
21
21
  * sem baixar o binário.
22
22
  */
23
23
  import type { Issue } from '../contract.js';
24
+ import { type DetailLookups } from './journal-detail.js';
24
25
  import { type ExtractionMap } from './json.js';
25
26
  /** Metadados de empacotamento do bundle Markdown (sem timestamp — determinismo). */
26
27
  export interface MarkdownBundleMeta {
@@ -33,6 +34,12 @@ export interface MarkdownBundleMeta {
33
34
  * a seção "Texto extraído" — o texto de OCR dentro de `<untrusted-content>`.
34
35
  */
35
36
  extractions?: ExtractionMap;
37
+ /**
38
+ * Dicionários `id → nome` da instância (status/tracker/prioridade/usuários).
39
+ * Sem eles, os ids HISTÓRICOS do journal saem como `#id` — legível, mas sem
40
+ * significado ("não sei o que é status 12").
41
+ */
42
+ lookups?: DetailLookups;
36
43
  }
37
44
  /**
38
45
  * Isola conteúdo não confiável num bloco `<untrusted-content>` de várias linhas.
@@ -20,7 +20,10 @@
20
20
  * No M1 os anexos são texto-only: referenciados pela URL de download do Redmine,
21
21
  * sem baixar o binário.
22
22
  */
23
+ import { journalDetailLabel, journalDetailValue, } from './journal-detail.js';
23
24
  import { byId, compareJournals } from './json.js';
25
+ /** Placeholder de valor ausente numa alteração de journal. */
26
+ const ABSENT_VALUE = '∅';
24
27
  /** Placeholder para ref degradada (id 0) — nunca um nome vazio silencioso. */
25
28
  const UNKNOWN_REF = '(desconhecido)';
26
29
  /** Placeholder para ref opcional ausente (distinto de degradada). */
@@ -124,21 +127,25 @@ function renderCustomFields(issue) {
124
127
  return ['## Custom Fields', '', ...rows].join('\n');
125
128
  }
126
129
  /**
127
- * Renderiza os `details` de um journal. `name` e old/new_value são derivados
128
- * (em details de "description"/"subject" os values carregam o texto completo
129
- * do campo) todos passam pelo fence inline.
130
+ * Renderiza os `details` de um journal.
131
+ *
132
+ * A SEMÂNTICA (rótulos, ids resolvidos) vem de `./journal-detail.js`, compartilhada
133
+ * com a TUI; aqui só se aplica a política deste formato: o que a semântica marcar
134
+ * como não confiável entra na fence anti prompt-injection.
130
135
  */
131
- function renderJournalDetails(journal) {
136
+ function renderJournalDetails(journal, issue, lookups) {
137
+ const fence = (part) => part === undefined ? ABSENT_VALUE : part.trusted ? part.text : fenceInline(part.text);
132
138
  return journal.details.map((detail) => {
133
- const from = detail.old_value === null || detail.old_value === undefined ? '∅' : fenceInline(detail.old_value);
134
- const to = detail.new_value === null || detail.new_value === undefined ? '∅' : fenceInline(detail.new_value);
135
- return ` - ${fenceInline(detail.name)}: ${from} → ${to}`;
139
+ const label = fence(journalDetailLabel(detail, issue));
140
+ const from = fence(journalDetailValue(detail, detail.old_value, issue, lookups));
141
+ const to = fence(journalDetailValue(detail, detail.new_value, issue, lookups));
142
+ return ` - ${label}: ${from} → ${to}`;
136
143
  });
137
144
  }
138
145
  /** Renderiza uma entrada de journal: cabeçalho estrutural + nota (fenced). */
139
- function renderJournal(journal) {
146
+ function renderJournal(journal, issue, lookups) {
140
147
  const lines = [`### Journal #${journal.id} — ${journal.created_on} — ${optionalRefName(journal.user)}`, ''];
141
- const details = renderJournalDetails(journal);
148
+ const details = renderJournalDetails(journal, issue, lookups);
142
149
  if (details.length > 0)
143
150
  lines.push('Alterações:', ...details, '');
144
151
  if (journal.notes !== undefined)
@@ -148,10 +155,12 @@ function renderJournal(journal) {
148
155
  return lines.join('\n').trimEnd();
149
156
  }
150
157
  /** Seção de histórico — journals em ordem cronológica estável (created_on, id). */
151
- function renderJournals(issue) {
158
+ function renderJournals(issue, lookups) {
152
159
  if (issue.journals.length === 0)
153
160
  return '## Histórico\n\n_(nenhum)_';
154
- const ordered = [...issue.journals].sort(compareJournals).map(renderJournal);
161
+ const ordered = [...issue.journals]
162
+ .sort(compareJournals)
163
+ .map((journal) => renderJournal(journal, issue, lookups));
155
164
  return ['## Histórico', ...ordered].join('\n\n');
156
165
  }
157
166
  /** Seção de relações — tipo + issue alvo (tudo estrutural). */
@@ -283,7 +292,7 @@ export function buildMarkdownBundle(issue, meta) {
283
292
  renderHeader(issue),
284
293
  renderDescription(issue),
285
294
  renderCustomFields(issue),
286
- renderJournals(issue),
295
+ renderJournals(issue, meta.lookups),
287
296
  renderRelations(issue),
288
297
  renderParent(issue),
289
298
  renderChildren(issue),
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Enumerações da instância (status, trackers, prioridades) — `id → nome`.
3
+ *
4
+ * O histórico de uma issue guarda apenas ids (`status_id: 12 → 7`), e a issue
5
+ * carrega o nome SÓ do estado atual. Sem estas listas, metade de cada alteração
6
+ * fica ilegível: "não sei o que é status 12".
7
+ *
8
+ * São coleções pequenas, estáveis e legíveis por qualquer usuário autenticado
9
+ * (não exigem admin, ao contrário de `/users.json`). O resultado é memoizado por
10
+ * instância no processo — a TUI e o servidor MCP são processos longos e não
11
+ * devem repetir estes GETs a cada issue.
12
+ *
13
+ * DEGRADAÇÃO (ADR-005): cada endpoint falha de forma independente e silenciosa.
14
+ * Uma instância que restrinja `/trackers.json` continua resolvendo status e
15
+ * prioridade; sem nenhum deles, o chamador volta a exibir `#id` — nunca um erro.
16
+ */
17
+ import type { HttpClient } from './http.js';
18
+ /** Mapas `id → nome` das enumerações usadas na leitura do histórico. */
19
+ export interface RedmineEnumerations {
20
+ /** `/issue_statuses.json` */
21
+ readonly status: ReadonlyMap<number, string>;
22
+ /** `/trackers.json` */
23
+ readonly tracker: ReadonlyMap<number, string>;
24
+ /** `/enumerations/issue_priorities.json` */
25
+ readonly priority: ReadonlyMap<number, string>;
26
+ }
27
+ /**
28
+ * Busca (e memoiza) as enumerações da instância.
29
+ *
30
+ * @param http - Client autenticado da instância.
31
+ * @param instanceKey - Chave de memoização (a URL base da instância).
32
+ * @returns Os mapas `id → nome`; vazios nos endpoints que falharem.
33
+ * @example
34
+ * const enums = await fetchEnumerations(http, baseUrl);
35
+ * enums.status.get(12); // 'Estimativa'
36
+ */
37
+ export declare function fetchEnumerations(http: HttpClient, instanceKey: string): Promise<RedmineEnumerations>;
38
+ /** Limpa a memoização (testes e troca de instância). */
39
+ export declare function clearEnumerationsCache(): void;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Enumerações da instância (status, trackers, prioridades) — `id → nome`.
3
+ *
4
+ * O histórico de uma issue guarda apenas ids (`status_id: 12 → 7`), e a issue
5
+ * carrega o nome SÓ do estado atual. Sem estas listas, metade de cada alteração
6
+ * fica ilegível: "não sei o que é status 12".
7
+ *
8
+ * São coleções pequenas, estáveis e legíveis por qualquer usuário autenticado
9
+ * (não exigem admin, ao contrário de `/users.json`). O resultado é memoizado por
10
+ * instância no processo — a TUI e o servidor MCP são processos longos e não
11
+ * devem repetir estes GETs a cada issue.
12
+ *
13
+ * DEGRADAÇÃO (ADR-005): cada endpoint falha de forma independente e silenciosa.
14
+ * Uma instância que restrinja `/trackers.json` continua resolvendo status e
15
+ * prioridade; sem nenhum deles, o chamador volta a exibir `#id` — nunca um erro.
16
+ */
17
+ /** Enumerações vazias — usado quando tudo falha (degradação total). */
18
+ const EMPTY = {
19
+ status: new Map(),
20
+ tracker: new Map(),
21
+ priority: new Map(),
22
+ };
23
+ /** Entradas memoizadas por instância. */
24
+ const memo = new Map();
25
+ /** Converte `{ chave: [{id, name}] }` em `Map<id, name>`; erro ⇒ mapa vazio. */
26
+ async function fetchMap(http, path, key) {
27
+ const out = new Map();
28
+ try {
29
+ const body = (await http.get(path, {}));
30
+ const rows = body[key];
31
+ if (!Array.isArray(rows))
32
+ return out;
33
+ for (const row of rows) {
34
+ if (typeof row !== 'object' || row === null)
35
+ continue;
36
+ const { id, name } = row;
37
+ if (typeof id === 'number' && typeof name === 'string' && name.length > 0)
38
+ out.set(id, name);
39
+ }
40
+ }
41
+ catch {
42
+ // Endpoint indisponível/sem permissão: degrada para mapa vazio (o chamador
43
+ // volta a exibir `#id`). Nunca propaga — isto é enriquecimento, não dado
44
+ // essencial do bundle.
45
+ }
46
+ return out;
47
+ }
48
+ /**
49
+ * Busca (e memoiza) as enumerações da instância.
50
+ *
51
+ * @param http - Client autenticado da instância.
52
+ * @param instanceKey - Chave de memoização (a URL base da instância).
53
+ * @returns Os mapas `id → nome`; vazios nos endpoints que falharem.
54
+ * @example
55
+ * const enums = await fetchEnumerations(http, baseUrl);
56
+ * enums.status.get(12); // 'Estimativa'
57
+ */
58
+ export function fetchEnumerations(http, instanceKey) {
59
+ const cached = memo.get(instanceKey);
60
+ if (cached !== undefined)
61
+ return cached;
62
+ const pending = (async () => {
63
+ const [status, tracker, priority] = await Promise.all([
64
+ fetchMap(http, '/issue_statuses.json', 'issue_statuses'),
65
+ fetchMap(http, '/trackers.json', 'trackers'),
66
+ fetchMap(http, '/enumerations/issue_priorities.json', 'issue_priorities'),
67
+ ]);
68
+ return { status, tracker, priority };
69
+ })().catch(() => EMPTY);
70
+ memo.set(instanceKey, pending);
71
+ return pending;
72
+ }
73
+ /** Limpa a memoização (testes e troca de instância). */
74
+ export function clearEnumerationsCache() {
75
+ memo.clear();
76
+ }
@@ -3,3 +3,4 @@ export { createHttpClient, redactSecret, validateBaseUrl, type HttpClient, type
3
3
  export { RedmineHttpError, RedmineAuthError, RedmineForbiddenError, RedmineNotFoundError, httpErrorFor, } from './errors.js';
4
4
  export { getIssue, listIssues, type RedmineIssuePayload, type ListIssuesOptions, } from './issues.js';
5
5
  export { searchIssues, SEARCH_MAX_LIMIT, type SearchIssuesOptions, type SearchIssuesPage, type RedmineSearchHit, } from './search.js';
6
+ export { fetchEnumerations, clearEnumerationsCache, type RedmineEnumerations, } from './enumerations.js';
@@ -3,3 +3,4 @@ export { createHttpClient, redactSecret, validateBaseUrl, } from './http.js';
3
3
  export { RedmineHttpError, RedmineAuthError, RedmineForbiddenError, RedmineNotFoundError, httpErrorFor, } from './errors.js';
4
4
  export { getIssue, listIssues, } from './issues.js';
5
5
  export { searchIssues, SEARCH_MAX_LIMIT, } from './search.js';
6
+ export { fetchEnumerations, clearEnumerationsCache, } from './enumerations.js';
@@ -13,7 +13,8 @@ import { buildJsonBundle } from './bundle/index.js';
13
13
  import { buildMarkdownBundle } from './bundle/index.js';
14
14
  import { extractIssueAttachmentsCacheFirst, makeQueueBackgroundExtractor, processingResult, } from './cache-first.js';
15
15
  import { DiskCacheStore } from './cache/index.js';
16
- import { createHttpClient, getIssue } from './client/index.js';
16
+ import { collectUsers } from './bundle/journal-detail.js';
17
+ import { createHttpClient, fetchEnumerations, getIssue } from './client/index.js';
17
18
  import { extractIssueAttachments } from './extract-issue-attachments.js';
18
19
  import { createDefaultRegistry } from './extract/index.js';
19
20
  import { normalizeIssue } from './normalize/index.js';
@@ -82,8 +83,22 @@ export async function* fetchIssueBundle(options) {
82
83
  });
83
84
  }
84
85
  }
86
+ // Dicionários da instância: sem eles os ids HISTÓRICOS do journal saem como
87
+ // `#id`. `fetchEnumerations` é memoizado por instância e degrada em silêncio,
88
+ // então o custo é uma vez por processo e a falha nunca derruba o bundle. Só
89
+ // vale a busca quando existe histórico para traduzir.
90
+ let lookups;
91
+ if (issue.journals.length > 0) {
92
+ const enums = await fetchEnumerations(http, baseUrl);
93
+ lookups = { ...enums, user: collectUsers(issue) };
94
+ }
85
95
  yield progress('bundle', `Empacotando bundle (${format})`);
86
- const meta = { baseUrl, toolVersion, ...(extractions !== undefined ? { extractions } : {}) };
96
+ const meta = {
97
+ baseUrl,
98
+ toolVersion,
99
+ ...(extractions !== undefined ? { extractions } : {}),
100
+ ...(lookups !== undefined ? { lookups } : {}),
101
+ };
87
102
  const content = format === 'json' ? buildJsonBundle(issue, meta).canonical : buildMarkdownBundle(issue, meta);
88
103
  const result = {
89
104
  kind: 'result',