redmine-context 1.0.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/LICENSE +21 -0
- package/README.md +449 -0
- package/dist/bundle/index.d.ts +5 -0
- package/dist/bundle/index.js +5 -0
- package/dist/bundle/json.d.ts +90 -0
- package/dist/bundle/json.js +266 -0
- package/dist/bundle/markdown.d.ts +75 -0
- package/dist/bundle/markdown.js +294 -0
- package/dist/bundle/search-list.d.ts +43 -0
- package/dist/bundle/search-list.js +53 -0
- package/dist/bundle/stable-stringify.d.ts +26 -0
- package/dist/bundle/stable-stringify.js +50 -0
- package/dist/cache/contract.d.ts +157 -0
- package/dist/cache/contract.js +0 -0
- package/dist/cache/disk-index.d.ts +82 -0
- package/dist/cache/disk-index.js +220 -0
- package/dist/cache/disk.d.ts +133 -0
- package/dist/cache/disk.js +313 -0
- package/dist/cache/gc.d.ts +78 -0
- package/dist/cache/gc.js +123 -0
- package/dist/cache/get-or-compute.d.ts +36 -0
- package/dist/cache/get-or-compute.js +52 -0
- package/dist/cache/index.d.ts +9 -0
- package/dist/cache/index.js +8 -0
- package/dist/cache/keys.d.ts +76 -0
- package/dist/cache/keys.js +78 -0
- package/dist/cache/memory.d.ts +48 -0
- package/dist/cache/memory.js +110 -0
- package/dist/cache-first.d.ts +127 -0
- package/dist/cache-first.js +227 -0
- package/dist/client/errors.d.ts +33 -0
- package/dist/client/errors.js +49 -0
- package/dist/client/http.d.ts +110 -0
- package/dist/client/http.js +207 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/client/issues.d.ts +71 -0
- package/dist/client/issues.js +100 -0
- package/dist/client/search.d.ts +58 -0
- package/dist/client/search.js +81 -0
- package/dist/config/credentials.d.ts +247 -0
- package/dist/config/credentials.js +427 -0
- package/dist/config/doctor.d.ts +123 -0
- package/dist/config/doctor.js +260 -0
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.js +6 -0
- package/dist/config/keyring.d.ts +96 -0
- package/dist/config/keyring.js +158 -0
- package/dist/config/login.d.ts +97 -0
- package/dist/config/login.js +189 -0
- package/dist/config/settings.d.ts +94 -0
- package/dist/config/settings.js +140 -0
- package/dist/contract.d.ts +173 -0
- package/dist/contract.js +27 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +8 -0
- package/dist/extract/audio-extractor.d.ts +105 -0
- package/dist/extract/audio-extractor.js +156 -0
- package/dist/extract/audio.d.ts +126 -0
- package/dist/extract/audio.js +184 -0
- package/dist/extract/dispatcher.d.ts +132 -0
- package/dist/extract/dispatcher.js +115 -0
- package/dist/extract/download.d.ts +111 -0
- package/dist/extract/download.js +261 -0
- package/dist/extract/duration.d.ts +106 -0
- package/dist/extract/duration.js +148 -0
- package/dist/extract/ffmpeg.d.ts +56 -0
- package/dist/extract/ffmpeg.js +95 -0
- package/dist/extract/gguf.d.ts +137 -0
- package/dist/extract/gguf.js +215 -0
- package/dist/extract/index.d.ts +19 -0
- package/dist/extract/index.js +19 -0
- package/dist/extract/magic.d.ts +80 -0
- package/dist/extract/magic.js +282 -0
- package/dist/extract/ooxml.d.ts +131 -0
- package/dist/extract/ooxml.js +336 -0
- package/dist/extract/pdf.d.ts +147 -0
- package/dist/extract/pdf.js +322 -0
- package/dist/extract/queue.d.ts +167 -0
- package/dist/extract/queue.js +217 -0
- package/dist/extract/subprocess.d.ts +145 -0
- package/dist/extract/subprocess.js +181 -0
- package/dist/extract/tesseract.d.ts +153 -0
- package/dist/extract/tesseract.js +321 -0
- package/dist/extract/video-extractor.d.ts +84 -0
- package/dist/extract/video-extractor.js +89 -0
- package/dist/extract/video.d.ts +198 -0
- package/dist/extract/video.js +418 -0
- package/dist/extract/which.d.ts +63 -0
- package/dist/extract/which.js +72 -0
- package/dist/extract/whisper-extract.d.ts +211 -0
- package/dist/extract/whisper-extract.js +323 -0
- package/dist/extract/whisper.d.ts +50 -0
- package/dist/extract/whisper.js +67 -0
- package/dist/extract/zip.d.ts +50 -0
- package/dist/extract/zip.js +165 -0
- package/dist/extract-issue-attachments.d.ts +82 -0
- package/dist/extract-issue-attachments.js +156 -0
- package/dist/fetch-attachment-text.d.ts +113 -0
- package/dist/fetch-attachment-text.js +155 -0
- package/dist/fetch-issue-bundle.d.ts +81 -0
- package/dist/fetch-issue-bundle.js +93 -0
- package/dist/fetch-issue-search.d.ts +74 -0
- package/dist/fetch-issue-search.js +120 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +67 -0
- package/dist/normalize/collections.d.ts +52 -0
- package/dist/normalize/collections.js +170 -0
- package/dist/normalize/helpers.d.ts +35 -0
- package/dist/normalize/helpers.js +59 -0
- package/dist/normalize/index.d.ts +2 -0
- package/dist/normalize/index.js +2 -0
- package/dist/normalize/issue.d.ts +34 -0
- package/dist/normalize/issue.js +154 -0
- package/dist/surfaces/cli/commands.d.ts +61 -0
- package/dist/surfaces/cli/commands.js +262 -0
- package/dist/surfaces/cli/main.d.ts +32 -0
- package/dist/surfaces/cli/main.js +210 -0
- package/dist/surfaces/cli/prompts.d.ts +66 -0
- package/dist/surfaces/cli/prompts.js +147 -0
- package/dist/surfaces/cli/tty.d.ts +27 -0
- package/dist/surfaces/cli/tty.js +35 -0
- package/dist/surfaces/cli/types.d.ts +39 -0
- package/dist/surfaces/cli/types.js +7 -0
- package/dist/surfaces/mcp/server.d.ts +171 -0
- package/dist/surfaces/mcp/server.js +427 -0
- package/dist/surfaces/tui/app.d.ts +55 -0
- package/dist/surfaces/tui/app.js +180 -0
- package/dist/surfaces/tui/attachment-status.d.ts +79 -0
- package/dist/surfaces/tui/attachment-status.js +113 -0
- package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
- package/dist/surfaces/tui/components/breadcrumb.js +31 -0
- package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
- package/dist/surfaces/tui/components/gradient-text.js +75 -0
- package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
- package/dist/surfaces/tui/components/scroll-view.js +77 -0
- package/dist/surfaces/tui/components/spinner.d.ts +12 -0
- package/dist/surfaces/tui/components/spinner.js +39 -0
- package/dist/surfaces/tui/components/text-input.d.ts +45 -0
- package/dist/surfaces/tui/components/text-input.js +114 -0
- package/dist/surfaces/tui/format-file-size.d.ts +24 -0
- package/dist/surfaces/tui/format-file-size.js +43 -0
- package/dist/surfaces/tui/glyphs.d.ts +47 -0
- package/dist/surfaces/tui/glyphs.js +84 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
- package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
- package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
- package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
- package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
- package/dist/surfaces/tui/index.d.ts +50 -0
- package/dist/surfaces/tui/index.js +158 -0
- package/dist/surfaces/tui/instance.d.ts +37 -0
- package/dist/surfaces/tui/instance.js +36 -0
- package/dist/surfaces/tui/job-registry.d.ts +113 -0
- package/dist/surfaces/tui/job-registry.js +123 -0
- package/dist/surfaces/tui/job-status.d.ts +45 -0
- package/dist/surfaces/tui/job-status.js +81 -0
- package/dist/surfaces/tui/navigation.d.ts +74 -0
- package/dist/surfaces/tui/navigation.js +87 -0
- package/dist/surfaces/tui/palettes.d.ts +38 -0
- package/dist/surfaces/tui/palettes.js +244 -0
- package/dist/surfaces/tui/screen.d.ts +30 -0
- package/dist/surfaces/tui/screen.js +51 -0
- package/dist/surfaces/tui/screens/about.d.ts +2 -0
- package/dist/surfaces/tui/screens/about.js +35 -0
- package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
- package/dist/surfaces/tui/screens/appearance.js +74 -0
- package/dist/surfaces/tui/screens/config.d.ts +2 -0
- package/dist/surfaces/tui/screens/config.js +82 -0
- package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
- package/dist/surfaces/tui/screens/doctor.js +109 -0
- package/dist/surfaces/tui/screens/export.d.ts +2 -0
- package/dist/surfaces/tui/screens/export.js +168 -0
- package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
- package/dist/surfaces/tui/screens/home-selection.js +80 -0
- package/dist/surfaces/tui/screens/home.d.ts +2 -0
- package/dist/surfaces/tui/screens/home.js +200 -0
- package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
- package/dist/surfaces/tui/screens/issue-detail.js +182 -0
- package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
- package/dist/surfaces/tui/screens/jobs.js +89 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
- package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
- package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
- package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
- package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
- package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
- package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
- package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
- package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
- package/dist/surfaces/tui/screens/welcome.js +95 -0
- package/dist/surfaces/tui/status-color.d.ts +21 -0
- package/dist/surfaces/tui/status-color.js +23 -0
- package/dist/surfaces/tui/symbols.d.ts +231 -0
- package/dist/surfaces/tui/symbols.js +14 -0
- package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
- package/dist/surfaces/tui/terminal-colors.js +39 -0
- package/dist/surfaces/tui/theme.d.ts +156 -0
- package/dist/surfaces/tui/theme.js +86 -0
- package/dist/surfaces/tui/truncate.d.ts +31 -0
- package/dist/surfaces/tui/truncate.js +81 -0
- package/package.json +93 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leitura CACHE-FIRST não-bloqueante das extrações de anexos (M4-11, ADR-004).
|
|
3
|
+
*
|
|
4
|
+
* O núcleo do modo cache-first do MCP (#70): dado uma issue JÁ normalizada, para
|
|
5
|
+
* cada anexo com extrator registrado LÊ o cache attachment-level e devolve
|
|
6
|
+
* IMEDIATAMENTE o que já está pronto (`done`/`text`). Quando a extração de um
|
|
7
|
+
* anexo pesado (vídeo/áudio) ainda NÃO está no cache, NÃO espera a extração
|
|
8
|
+
* terminar: marca o anexo como {@link ExtractionStatus} `processing` com
|
|
9
|
+
* metadados e, opcionalmente, dispara o job de extração em SEGUNDO PLANO via a
|
|
10
|
+
* fila (`runQueue`, #67) — fire-and-forget. Assim nenhuma chamada MCP bloqueia
|
|
11
|
+
* aguardando mídia (resposta imediata; a continuação que completa na 2ª chamada
|
|
12
|
+
* é a #71).
|
|
13
|
+
*
|
|
14
|
+
* A leitura de cache (índice/`extraction.json`) é barata (ms); só a COMPUTAÇÃO da
|
|
15
|
+
* extração é cara — e é justamente ela que este módulo evita no caminho síncrono.
|
|
16
|
+
* A chave attachment-level é montada EXATAMENTE como no caminho bloqueante
|
|
17
|
+
* ({@link extractIssueAttachments}) — mesmos `probableMime`/`toExtractorConfig` —
|
|
18
|
+
* garantindo cache-hit coerente com o que o job de background grava.
|
|
19
|
+
*
|
|
20
|
+
* Fronteira ADR-005: módulo de core (pipeline de extração). Não importa de
|
|
21
|
+
* `src/surfaces/**` e nunca usa `console.*` — avisos vão pelo {@link Logger}.
|
|
22
|
+
*/
|
|
23
|
+
import { buildAttachmentKey, serializeCacheKey, } from './cache/index.js';
|
|
24
|
+
import { probableMime, toExtractorConfig } from './extract-issue-attachments.js';
|
|
25
|
+
import { runQueue } from './extract/index.js';
|
|
26
|
+
/** Extrai uma mensagem legível de um erro desconhecido. */
|
|
27
|
+
function messageOf(error) {
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Constrói um {@link ExtractionResult} `processing` — o anexo existe e TEM
|
|
32
|
+
* extrator, mas a extração (cara) ainda não está no cache. Sinaliza à superfície
|
|
33
|
+
* que o texto virá em uma consulta futura (#71), sem bloquear a atual.
|
|
34
|
+
*
|
|
35
|
+
* @returns Resultado `processing` com motivo/dica legíveis (sem texto).
|
|
36
|
+
*/
|
|
37
|
+
export function processingResult() {
|
|
38
|
+
return {
|
|
39
|
+
status: 'processing',
|
|
40
|
+
metadata: {
|
|
41
|
+
reason: 'extracao-em-andamento',
|
|
42
|
+
hint: 'anexo ainda nao processado; a extracao roda em segundo plano — consulte novamente em instantes',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Lê o cache das extrações de TODOS os anexos com extrator registrado de uma
|
|
48
|
+
* issue, SEM computar nenhuma extração (não-bloqueante).
|
|
49
|
+
*
|
|
50
|
+
* Para cada anexo:
|
|
51
|
+
* - sem extrator para o MIME provável → nem entra no mapa (igual ao bloqueante);
|
|
52
|
+
* - cache-hit → o {@link ExtractionResult} pronto (texto imediato);
|
|
53
|
+
* - cache-miss → `processing` + (opcional) dispara o job de background.
|
|
54
|
+
*
|
|
55
|
+
* @param issue - Issue normalizada cujos anexos serão consultados no cache.
|
|
56
|
+
* @param options - Ver {@link CacheFirstExtractionOptions}.
|
|
57
|
+
* @returns `Map<attachmentId, ExtractionResult>` só com anexos que têm extrator.
|
|
58
|
+
* @example
|
|
59
|
+
* const map = await extractIssueAttachmentsCacheFirst(issue, {
|
|
60
|
+
* instanceUrl, registry, store, background,
|
|
61
|
+
* });
|
|
62
|
+
*/
|
|
63
|
+
export async function extractIssueAttachmentsCacheFirst(issue, options) {
|
|
64
|
+
const { instanceUrl, registry, store, background, logger } = options;
|
|
65
|
+
const result = new Map();
|
|
66
|
+
for (const attachment of issue.attachments) {
|
|
67
|
+
const mime = probableMime(attachment);
|
|
68
|
+
const extractor = mime !== undefined ? registry.find(mime) : undefined;
|
|
69
|
+
if (extractor === undefined) {
|
|
70
|
+
// Sem extrator: não há trabalho a fazer — não entra no mapa (paridade com
|
|
71
|
+
// o caminho bloqueante `extractIssueAttachments`).
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const key = buildAttachmentKey({ instanceUrl, attachment, extractor: toExtractorConfig(extractor) });
|
|
75
|
+
let cached;
|
|
76
|
+
try {
|
|
77
|
+
cached = await store.get(key);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// Falha de leitura de cache não bloqueia nem derruba: trata como miss.
|
|
81
|
+
logger?.warn(`cache-first: falha ao ler o cache do anexo #${attachment.id}: ${messageOf(error)}`);
|
|
82
|
+
cached = undefined;
|
|
83
|
+
}
|
|
84
|
+
if (cached !== undefined) {
|
|
85
|
+
result.set(attachment.id, cached);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Cache-miss: NÃO espera a extração cara — sinaliza `processing` na hora e,
|
|
89
|
+
// se um dispatcher foi injetado, enfileira o job em background (fire-and-forget).
|
|
90
|
+
result.set(attachment.id, processingResult());
|
|
91
|
+
background?.({ attachment, extractor, key });
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Constrói um {@link ExtractionResult} `failed` para PERSISTIR quando o job de
|
|
97
|
+
* background falha (compute lança/rejeita). É o que impede o `processing` eterno:
|
|
98
|
+
* sem um `failed` no cache, a próxima chamada re-dispararia o job em loop.
|
|
99
|
+
*
|
|
100
|
+
* @param reason - Motivo legível da falha (mensagem do erro/`reason` da fila).
|
|
101
|
+
* @returns Resultado `failed` com o motivo preservado no `metadata`.
|
|
102
|
+
*/
|
|
103
|
+
function backgroundFailedResult(reason) {
|
|
104
|
+
return {
|
|
105
|
+
status: 'failed',
|
|
106
|
+
metadata: {
|
|
107
|
+
reason: 'background-extracao-falhou',
|
|
108
|
+
error: reason ?? 'motivo desconhecido',
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Grava `value` no cache sob `key` APENAS se ainda não houver valor — idempotente
|
|
114
|
+
* e sem clobber. Uma falha de IO do próprio cache vira aviso (nunca derruba o job
|
|
115
|
+
* de background). É o ponto que fecha o loop do #71: garante que, após o job, a
|
|
116
|
+
* chave attachment-level NUNCA fique vazia (senão a próxima chamada re-dispara).
|
|
117
|
+
*
|
|
118
|
+
* @param store - Store attachment-level a persistir.
|
|
119
|
+
* @param key - Chave attachment-level (idêntica à lida no cache-first).
|
|
120
|
+
* @param value - Resultado a gravar quando a chave está ausente.
|
|
121
|
+
* @param attachmentId - Id do anexo (para compor a mensagem de aviso).
|
|
122
|
+
* @param logger - Sink de avisos; ausente = silencioso.
|
|
123
|
+
*/
|
|
124
|
+
async function persistIfAbsent(store, key, value, attachmentId, logger) {
|
|
125
|
+
try {
|
|
126
|
+
const existing = await store.get(key);
|
|
127
|
+
if (existing !== undefined) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
await store.put(key, value);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
logger?.warn(`cache-first: falha ao persistir o resultado do anexo #${attachmentId}: ${messageOf(error)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Consome, em segundo plano, as transições de uma fila de background — sem
|
|
138
|
+
* bloquear o chamador — e FECHA O LOOP do #71: quando `store` é dado, PERSISTE o
|
|
139
|
+
* resultado do job sob a chave do alvo para que a 2ª chamada acerte o cache.
|
|
140
|
+
*
|
|
141
|
+
* - `done`: grava o resultado (idempotente/if-absent). Cobre o caso em que o
|
|
142
|
+
* pipeline devolveu `failed` capturado por anexo SEM persistir — sem isso a
|
|
143
|
+
* chave ficaria vazia e a próxima chamada re-dispararia (processing eterno).
|
|
144
|
+
* - `failed`: avisa e PERSISTE um `failed`, garantindo que uma falha do job vire
|
|
145
|
+
* `failed` no cache — NUNCA `processing` para sempre.
|
|
146
|
+
*
|
|
147
|
+
* Falhas/cancelamentos nunca são relançados (a resposta MCP já voltou).
|
|
148
|
+
*
|
|
149
|
+
* @param events - Sequência de transições de {@link runQueue}.
|
|
150
|
+
* @param target - Alvo do cache-miss (anexo + chave attachment-level).
|
|
151
|
+
* @param logger - Sink de avisos; ausente = silencioso.
|
|
152
|
+
* @param store - Store onde persistir o resultado de fechamento; ausente = não persiste.
|
|
153
|
+
*/
|
|
154
|
+
async function drainDetached(events, target, logger, store) {
|
|
155
|
+
const attachmentId = target.attachment.id;
|
|
156
|
+
try {
|
|
157
|
+
for await (const event of events) {
|
|
158
|
+
if (event.status === 'done') {
|
|
159
|
+
// Nunca persistir um `processing` (invariante do #71): cachear `processing`
|
|
160
|
+
// faria a 2ª chamada acertar o cache em `processing` e nunca re-disparar.
|
|
161
|
+
if (store !== undefined && event.result !== undefined && event.result.status !== 'processing') {
|
|
162
|
+
await persistIfAbsent(store, target.key, event.result, attachmentId, logger);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (event.status === 'failed') {
|
|
166
|
+
logger?.warn(`cache-first: extracao em background do anexo #${attachmentId} falhou: ${event.reason ?? 'motivo desconhecido'}`);
|
|
167
|
+
if (store !== undefined) {
|
|
168
|
+
await persistIfAbsent(store, target.key, backgroundFailedResult(event.reason), attachmentId, logger);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
logger?.warn(`cache-first: fila de background do anexo #${attachmentId} abortou: ${messageOf(error)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Cria um {@link BackgroundExtractor} respaldado pela fila de jobs (#67): cada
|
|
179
|
+
* anexo com cache-miss vira UM {@link QueueJob} rodado por `runQueue`, cujas
|
|
180
|
+
* transições são consumidas de forma DESACOPLADA (fire-and-forget). O dispatcher
|
|
181
|
+
* retorna imediatamente — o trabalho caro acontece na fila, fora do caminho da
|
|
182
|
+
* resposta MCP. O `AbortSignal` da fila é propagado ao `compute` via
|
|
183
|
+
* {@link JobContext.signal} (#69), pronto para o kill de subprocesso do #68.
|
|
184
|
+
*
|
|
185
|
+
* DEDUP DE IN-FLIGHT (#71): o dispatcher mantém um registro dos jobs em andamento
|
|
186
|
+
* POR CHAVE attachment-level. Uma 2ª chamada para um anexo cujo job ainda processa
|
|
187
|
+
* NÃO dispara um job duplicado — reusa o que já corre. O registro é limpo quando o
|
|
188
|
+
* job conclui (via `finally`), liberando futuras re-extrações (ex.: cache invalidado).
|
|
189
|
+
*
|
|
190
|
+
* @param compute - Executa a extração real do anexo — ver {@link BackgroundCompute}.
|
|
191
|
+
* @param options - Logger/signal/store opcionais — ver {@link QueueBackgroundOptions}.
|
|
192
|
+
* @returns Um dispatcher fire-and-forget para injetar em {@link CacheFirstExtractionOptions.background}.
|
|
193
|
+
* @example
|
|
194
|
+
* const background = makeQueueBackgroundExtractor(async (target) => {
|
|
195
|
+
* const map = await extractIssueAttachments(http, single, { store, registry, instanceUrl });
|
|
196
|
+
* return map.get(target.attachment.id) ?? processingResult();
|
|
197
|
+
* }, { store });
|
|
198
|
+
*/
|
|
199
|
+
export function makeQueueBackgroundExtractor(compute, options = {}) {
|
|
200
|
+
const { logger, signal, store } = options;
|
|
201
|
+
// Registro de jobs em andamento por chave serializada: dedup de disparo (#71).
|
|
202
|
+
const inFlight = new Map();
|
|
203
|
+
return (target) => {
|
|
204
|
+
const keyId = serializeCacheKey(target.key);
|
|
205
|
+
// Dedup: se já há um job em andamento para esta chave, não dispara outro —
|
|
206
|
+
// reusa o existente (evita reprocessar o mesmo anexo em chamadas concorrentes).
|
|
207
|
+
if (inFlight.has(keyId)) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const job = {
|
|
211
|
+
id: `attachment:${target.attachment.id}`,
|
|
212
|
+
run: (context) => compute(target, context.signal),
|
|
213
|
+
};
|
|
214
|
+
const queueOptions = {
|
|
215
|
+
...(logger !== undefined ? { logger } : {}),
|
|
216
|
+
...(signal !== undefined ? { signal } : {}),
|
|
217
|
+
};
|
|
218
|
+
// Fire-and-forget: consome as transições em segundo plano (persistindo o
|
|
219
|
+
// resultado de fechamento no `store`, #71); NUNCA bloqueia nem relança (a
|
|
220
|
+
// resposta MCP já voltou com `processing`). Ao concluir, libera a chave.
|
|
221
|
+
const settled = drainDetached(runQueue([job], queueOptions), target, logger, store).finally(() => {
|
|
222
|
+
inFlight.delete(keyId);
|
|
223
|
+
});
|
|
224
|
+
inFlight.set(keyId, settled);
|
|
225
|
+
void settled;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Erros tipados do client HTTP do Redmine (ADR-003).
|
|
3
|
+
*
|
|
4
|
+
* Union por classe (instanceof + `status`) — o mais simples para o M1.
|
|
5
|
+
* Nenhuma mensagem carrega a api_key: a redação acontece na camada de request
|
|
6
|
+
* (ver `redactSecret` em `./http.ts`) antes de qualquer texto chegar aqui.
|
|
7
|
+
*/
|
|
8
|
+
/** Erro base de qualquer resposta HTTP ≥ 400 do Redmine. */
|
|
9
|
+
export declare class RedmineHttpError extends Error {
|
|
10
|
+
/** Código HTTP da resposta (ex.: 500). */
|
|
11
|
+
readonly status: number;
|
|
12
|
+
/** URL já redigida (sem api_key) — segura para log. */
|
|
13
|
+
readonly url: string;
|
|
14
|
+
constructor(message: string, status: number, url: string);
|
|
15
|
+
}
|
|
16
|
+
/** 401 — api_key ausente/inválida. */
|
|
17
|
+
export declare class RedmineAuthError extends RedmineHttpError {
|
|
18
|
+
}
|
|
19
|
+
/** 403 — autenticado, porém sem permissão para o recurso. */
|
|
20
|
+
export declare class RedmineForbiddenError extends RedmineHttpError {
|
|
21
|
+
}
|
|
22
|
+
/** 404 — recurso inexistente. */
|
|
23
|
+
export declare class RedmineNotFoundError extends RedmineHttpError {
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Mapeia um status HTTP para a classe de erro correspondente.
|
|
27
|
+
*
|
|
28
|
+
* @param message - Mensagem já redigida (sem segredos).
|
|
29
|
+
* @param status - Código HTTP da resposta.
|
|
30
|
+
* @param url - URL já redigida (sem api_key).
|
|
31
|
+
* @returns Instância do erro tipado adequado ao status.
|
|
32
|
+
*/
|
|
33
|
+
export declare function httpErrorFor(message: string, status: number, url: string): RedmineHttpError;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Erros tipados do client HTTP do Redmine (ADR-003).
|
|
3
|
+
*
|
|
4
|
+
* Union por classe (instanceof + `status`) — o mais simples para o M1.
|
|
5
|
+
* Nenhuma mensagem carrega a api_key: a redação acontece na camada de request
|
|
6
|
+
* (ver `redactSecret` em `./http.ts`) antes de qualquer texto chegar aqui.
|
|
7
|
+
*/
|
|
8
|
+
/** Erro base de qualquer resposta HTTP ≥ 400 do Redmine. */
|
|
9
|
+
export class RedmineHttpError extends Error {
|
|
10
|
+
/** Código HTTP da resposta (ex.: 500). */
|
|
11
|
+
status;
|
|
12
|
+
/** URL já redigida (sem api_key) — segura para log. */
|
|
13
|
+
url;
|
|
14
|
+
constructor(message, status, url) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = new.target.name;
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.url = url;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** 401 — api_key ausente/inválida. */
|
|
22
|
+
export class RedmineAuthError extends RedmineHttpError {
|
|
23
|
+
}
|
|
24
|
+
/** 403 — autenticado, porém sem permissão para o recurso. */
|
|
25
|
+
export class RedmineForbiddenError extends RedmineHttpError {
|
|
26
|
+
}
|
|
27
|
+
/** 404 — recurso inexistente. */
|
|
28
|
+
export class RedmineNotFoundError extends RedmineHttpError {
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Mapeia um status HTTP para a classe de erro correspondente.
|
|
32
|
+
*
|
|
33
|
+
* @param message - Mensagem já redigida (sem segredos).
|
|
34
|
+
* @param status - Código HTTP da resposta.
|
|
35
|
+
* @param url - URL já redigida (sem api_key).
|
|
36
|
+
* @returns Instância do erro tipado adequado ao status.
|
|
37
|
+
*/
|
|
38
|
+
export function httpErrorFor(message, status, url) {
|
|
39
|
+
switch (status) {
|
|
40
|
+
case 401:
|
|
41
|
+
return new RedmineAuthError(message, status, url);
|
|
42
|
+
case 403:
|
|
43
|
+
return new RedmineForbiddenError(message, status, url);
|
|
44
|
+
case 404:
|
|
45
|
+
return new RedmineNotFoundError(message, status, url);
|
|
46
|
+
default:
|
|
47
|
+
return new RedmineHttpError(message, status, url);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client HTTP base do Redmine — autenticação por api_key e TLS obrigatório (ADR-003).
|
|
3
|
+
*
|
|
4
|
+
* `GET` (JSON) com retry/backoff exponencial (issue #10) para 429/5xx/erros de
|
|
5
|
+
* rede, mais `getBinary` (download bruto de anexos, issue #48) que herda a mesma
|
|
6
|
+
* auth/TLS/retry sem parse JSON. Sem paginação ou timeout sofisticado (issue #9).
|
|
7
|
+
* Usa `fetch` nativo do Node ≥ 20, sem dependência nova.
|
|
8
|
+
*/
|
|
9
|
+
/** Logger mínimo — só o nível de aviso é necessário neste módulo. */
|
|
10
|
+
export interface Logger {
|
|
11
|
+
warn(message: string): void;
|
|
12
|
+
}
|
|
13
|
+
/** Parâmetros de query aceitos por uma requisição GET. */
|
|
14
|
+
export type QueryParams = Record<string, string | number | boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Ajustes do retry com backoff exponencial (issue #10). Só se aplica a falhas
|
|
17
|
+
* transientes: 429, 5xx e erros de rede. Erros 4xx (exceto 429) nunca retentam.
|
|
18
|
+
*/
|
|
19
|
+
export interface RetryOptions {
|
|
20
|
+
/** Número máximo de tentativas (inclui a primeira). Default: 3; mínimo 1. */
|
|
21
|
+
maxAttempts?: number;
|
|
22
|
+
/** Atraso base em ms para o backoff exponencial (`base * 2^tentativa`). Default: 250. */
|
|
23
|
+
baseDelayMs?: number;
|
|
24
|
+
}
|
|
25
|
+
/** Opções de construção do client HTTP. */
|
|
26
|
+
export interface HttpClientOptions {
|
|
27
|
+
/** URL base da instância Redmine (ex.: `https://redmine.example`). */
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
/** api_key do usuário — enviada por header (ou query, ver `keyInQuery`). */
|
|
30
|
+
apiKey: string;
|
|
31
|
+
/**
|
|
32
|
+
* Envia a api_key como `?key=` em vez do header `X-Redmine-API-Key`.
|
|
33
|
+
* Fallback explícito para proxies que removem headers (ADR-003). Default: `false`.
|
|
34
|
+
*/
|
|
35
|
+
keyInQuery?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Permite `http://` (sem TLS). Opt-in explícito e ruidoso: emite um `warn`.
|
|
38
|
+
* Default: `false` (só `https://` é aceito).
|
|
39
|
+
*/
|
|
40
|
+
insecure?: boolean;
|
|
41
|
+
/** Logger para avisos; default no-op (não introduz lib de logging no M1). */
|
|
42
|
+
logger?: Logger;
|
|
43
|
+
/** Política de retry para falhas transientes. Ver {@link RetryOptions}. */
|
|
44
|
+
retry?: RetryOptions;
|
|
45
|
+
}
|
|
46
|
+
/** Client HTTP do core — expõe `get` (JSON) e `getBinary` (download bruto). */
|
|
47
|
+
export interface HttpClient {
|
|
48
|
+
/**
|
|
49
|
+
* Executa `GET {baseUrl}{path}` com autenticação e retorna o JSON já parseado.
|
|
50
|
+
*
|
|
51
|
+
* @param path - Caminho absoluto na API (ex.: `/issues/100.json`).
|
|
52
|
+
* @param params - Parâmetros de query opcionais.
|
|
53
|
+
* @returns Corpo da resposta parseado como JSON.
|
|
54
|
+
* @throws {RedmineAuthError} Em 401.
|
|
55
|
+
* @throws {RedmineForbiddenError} Em 403.
|
|
56
|
+
* @throws {RedmineNotFoundError} Em 404.
|
|
57
|
+
* @throws {RedmineHttpError} Em qualquer outro status ≥ 400.
|
|
58
|
+
*/
|
|
59
|
+
get(path: string, params?: QueryParams): Promise<unknown>;
|
|
60
|
+
/**
|
|
61
|
+
* Executa `GET {baseUrl}{path}` autenticado e retorna o corpo BRUTO como um
|
|
62
|
+
* stream de bytes, SEM parse JSON — usado para baixar anexos (M3-06, ADR-004).
|
|
63
|
+
*
|
|
64
|
+
* Reusa a mesma autenticação, política de TLS e retry/backoff transiente de
|
|
65
|
+
* {@link get}; os erros são os mesmos tipos ({@link RedmineHttpError} e
|
|
66
|
+
* subclasses). O chamador é responsável por consumir/encerrar o stream.
|
|
67
|
+
*
|
|
68
|
+
* @param path - Caminho absoluto na API (ex.: `/attachments/download/7/foo.png`).
|
|
69
|
+
* @param params - Parâmetros de query opcionais.
|
|
70
|
+
* @returns Stream de leitura dos bytes da resposta.
|
|
71
|
+
* @throws {RedmineAuthError} Em 401.
|
|
72
|
+
* @throws {RedmineForbiddenError} Em 403.
|
|
73
|
+
* @throws {RedmineNotFoundError} Em 404.
|
|
74
|
+
* @throws {RedmineHttpError} Em qualquer outro status ≥ 400.
|
|
75
|
+
* @throws {Error} Se a resposta vier sem corpo.
|
|
76
|
+
*/
|
|
77
|
+
getBinary(path: string, params?: QueryParams): Promise<ReadableStream<Uint8Array>>;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Remove qualquer ocorrência da api_key (em texto ou em `?key=`) de uma string.
|
|
81
|
+
* Garante que nenhum segredo vaze para mensagens de erro ou logs (ADR-003).
|
|
82
|
+
*
|
|
83
|
+
* @param text - Texto potencialmente contendo o segredo.
|
|
84
|
+
* @param apiKey - Segredo a redigir.
|
|
85
|
+
* @returns Texto com o segredo substituído por `[REDACTED]`.
|
|
86
|
+
*/
|
|
87
|
+
export declare function redactSecret(text: string, apiKey: string): string;
|
|
88
|
+
/**
|
|
89
|
+
* Valida o esquema da URL base conforme a política de TLS do ADR-003.
|
|
90
|
+
*
|
|
91
|
+
* Exportada para ser reutilizada por outros fluxos de autenticação (ex.: login
|
|
92
|
+
* por senha em `config/login.ts`), evitando duplicar a política de TLS.
|
|
93
|
+
*
|
|
94
|
+
* @param baseUrl - URL base a validar.
|
|
95
|
+
* @param insecure - Se `true`, permite `http://` com aviso ruidoso.
|
|
96
|
+
* @param logger - Logger para o aviso de conexão insegura.
|
|
97
|
+
* @throws {Error} Se `http://` for usado sem `insecure`, ou se o esquema for inválido.
|
|
98
|
+
*/
|
|
99
|
+
export declare function validateBaseUrl(baseUrl: string, insecure: boolean, logger: Logger): void;
|
|
100
|
+
/**
|
|
101
|
+
* Cria um client HTTP autenticado para uma instância Redmine.
|
|
102
|
+
*
|
|
103
|
+
* @param options - Configuração do client (ver {@link HttpClientOptions}).
|
|
104
|
+
* @returns Um {@link HttpClient} com o método `get`.
|
|
105
|
+
* @throws {Error} Se `apiKey` for vazia ou a política de TLS for violada.
|
|
106
|
+
* @example
|
|
107
|
+
* const client = createHttpClient({ baseUrl: 'https://redmine.example', apiKey: KEY });
|
|
108
|
+
* const issue = await client.get('/issues/100.json');
|
|
109
|
+
*/
|
|
110
|
+
export declare function createHttpClient(options: HttpClientOptions): HttpClient;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client HTTP base do Redmine — autenticação por api_key e TLS obrigatório (ADR-003).
|
|
3
|
+
*
|
|
4
|
+
* `GET` (JSON) com retry/backoff exponencial (issue #10) para 429/5xx/erros de
|
|
5
|
+
* rede, mais `getBinary` (download bruto de anexos, issue #48) que herda a mesma
|
|
6
|
+
* auth/TLS/retry sem parse JSON. Sem paginação ou timeout sofisticado (issue #9).
|
|
7
|
+
* Usa `fetch` nativo do Node ≥ 20, sem dependência nova.
|
|
8
|
+
*/
|
|
9
|
+
import { httpErrorFor } from './errors.js';
|
|
10
|
+
/** Header do Redmine para autenticação por api_key (ADR-003). */
|
|
11
|
+
const API_KEY_HEADER = 'X-Redmine-API-Key';
|
|
12
|
+
/** Placeholder usado ao redigir segredos em mensagens/logs. */
|
|
13
|
+
const REDACTED = '[REDACTED]';
|
|
14
|
+
const noopLogger = { warn: () => undefined };
|
|
15
|
+
/** Tentativas default quando `retry.maxAttempts` não é informado. */
|
|
16
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
17
|
+
/** Atraso base (ms) default do backoff quando `retry.baseDelayMs` não é informado. */
|
|
18
|
+
const DEFAULT_BASE_DELAY_MS = 250;
|
|
19
|
+
/** Aguarda `ms` milissegundos; isolado num `setTimeout` para ser mockável com fake timers. */
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Indica se um status HTTP é transiente e merece nova tentativa.
|
|
25
|
+
* Retentam: 429 (throttling) e 5xx (falha do servidor). Demais 4xx são definitivos.
|
|
26
|
+
*
|
|
27
|
+
* @param status - Código HTTP da resposta.
|
|
28
|
+
* @returns `true` se vale a pena retentar.
|
|
29
|
+
*/
|
|
30
|
+
function isRetryableStatus(status) {
|
|
31
|
+
return status === 429 || status >= 500;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Calcula o atraso do backoff exponencial com jitter para a tentativa `attempt` (0-based).
|
|
35
|
+
* `baseDelay * 2^attempt` mais um jitter aleatório pequeno para evitar thundering herd.
|
|
36
|
+
*
|
|
37
|
+
* @param attempt - Índice da tentativa já realizada (0 na primeira falha).
|
|
38
|
+
* @param baseDelayMs - Atraso base em milissegundos.
|
|
39
|
+
* @returns Atraso, em milissegundos, antes da próxima tentativa.
|
|
40
|
+
*/
|
|
41
|
+
function backoffDelay(attempt, baseDelayMs) {
|
|
42
|
+
const exponential = baseDelayMs * 2 ** attempt;
|
|
43
|
+
const jitter = Math.random() * baseDelayMs;
|
|
44
|
+
return exponential + jitter;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Remove qualquer ocorrência da api_key (em texto ou em `?key=`) de uma string.
|
|
48
|
+
* Garante que nenhum segredo vaze para mensagens de erro ou logs (ADR-003).
|
|
49
|
+
*
|
|
50
|
+
* @param text - Texto potencialmente contendo o segredo.
|
|
51
|
+
* @param apiKey - Segredo a redigir.
|
|
52
|
+
* @returns Texto com o segredo substituído por `[REDACTED]`.
|
|
53
|
+
*/
|
|
54
|
+
export function redactSecret(text, apiKey) {
|
|
55
|
+
let out = text;
|
|
56
|
+
if (apiKey.length > 0) {
|
|
57
|
+
out = out.split(apiKey).join(REDACTED);
|
|
58
|
+
// URLs são percent-encoded: uma key com caracteres URL-unsafe não casaria
|
|
59
|
+
// com a forma bruta acima.
|
|
60
|
+
const encoded = encodeURIComponent(apiKey);
|
|
61
|
+
if (encoded !== apiKey) {
|
|
62
|
+
out = out.split(encoded).join(REDACTED);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Redige também o valor de `key=` na querystring, caso a URL tenha sido montada
|
|
66
|
+
// com um segredo diferente do informado (defesa em profundidade).
|
|
67
|
+
return out.replace(/([?&]key=)[^&#\s]+/gi, `$1${REDACTED}`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Valida o esquema da URL base conforme a política de TLS do ADR-003.
|
|
71
|
+
*
|
|
72
|
+
* Exportada para ser reutilizada por outros fluxos de autenticação (ex.: login
|
|
73
|
+
* por senha em `config/login.ts`), evitando duplicar a política de TLS.
|
|
74
|
+
*
|
|
75
|
+
* @param baseUrl - URL base a validar.
|
|
76
|
+
* @param insecure - Se `true`, permite `http://` com aviso ruidoso.
|
|
77
|
+
* @param logger - Logger para o aviso de conexão insegura.
|
|
78
|
+
* @throws {Error} Se `http://` for usado sem `insecure`, ou se o esquema for inválido.
|
|
79
|
+
*/
|
|
80
|
+
export function validateBaseUrl(baseUrl, insecure, logger) {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = new URL(baseUrl);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error(`baseUrl inválida: "${baseUrl}". Informe uma URL absoluta (ex.: https://redmine.example).`);
|
|
87
|
+
}
|
|
88
|
+
if (parsed.protocol === 'https:') {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (parsed.protocol === 'http:') {
|
|
92
|
+
if (!insecure) {
|
|
93
|
+
throw new Error(`Conexão http:// recusada para "${parsed.origin}": TLS é obrigatório. ` +
|
|
94
|
+
`Use https:// ou, para uma CA interna, configure NODE_EXTRA_CA_CERTS. ` +
|
|
95
|
+
`Para ignorar a verificação (NÃO recomendado), passe insecure: true.`);
|
|
96
|
+
}
|
|
97
|
+
logger.warn(`AVISO DE SEGURANÇA: conexão insegura (http://) habilitada para "${parsed.origin}". ` +
|
|
98
|
+
`O tráfego, incluindo a api_key, NÃO é criptografado.`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`Esquema não suportado em baseUrl: "${parsed.protocol}". Use https://.`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Cria um client HTTP autenticado para uma instância Redmine.
|
|
105
|
+
*
|
|
106
|
+
* @param options - Configuração do client (ver {@link HttpClientOptions}).
|
|
107
|
+
* @returns Um {@link HttpClient} com o método `get`.
|
|
108
|
+
* @throws {Error} Se `apiKey` for vazia ou a política de TLS for violada.
|
|
109
|
+
* @example
|
|
110
|
+
* const client = createHttpClient({ baseUrl: 'https://redmine.example', apiKey: KEY });
|
|
111
|
+
* const issue = await client.get('/issues/100.json');
|
|
112
|
+
*/
|
|
113
|
+
export function createHttpClient(options) {
|
|
114
|
+
const { baseUrl, apiKey, keyInQuery = false, insecure = false } = options;
|
|
115
|
+
const logger = options.logger ?? noopLogger;
|
|
116
|
+
const maxAttempts = Math.max(1, options.retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
117
|
+
const baseDelayMs = options.retry?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
118
|
+
if (apiKey.length === 0) {
|
|
119
|
+
throw new Error('apiKey é obrigatória e não pode ser vazia.');
|
|
120
|
+
}
|
|
121
|
+
validateBaseUrl(baseUrl, insecure, logger);
|
|
122
|
+
const redact = (text) => redactSecret(text, apiKey);
|
|
123
|
+
/**
|
|
124
|
+
* Monta a URL final da requisição, aplicando params e (se configurado) `?key=`.
|
|
125
|
+
*/
|
|
126
|
+
const buildUrl = (path, params) => {
|
|
127
|
+
const url = new URL(path, baseUrl);
|
|
128
|
+
if (params) {
|
|
129
|
+
for (const [name, value] of Object.entries(params)) {
|
|
130
|
+
url.searchParams.set(name, String(value));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (keyInQuery) {
|
|
134
|
+
url.searchParams.set('key', apiKey);
|
|
135
|
+
}
|
|
136
|
+
return url;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Executa a requisição `GET` com autenticação e retry transiente, devolvendo a
|
|
140
|
+
* `Response` já validada (status < 400) mais a URL redigida para mensagens de
|
|
141
|
+
* erro. Compartilhado por {@link HttpClient.get} (JSON) e
|
|
142
|
+
* {@link HttpClient.getBinary} (bytes), para que ambos herdem a MESMA política
|
|
143
|
+
* de auth/TLS/retry e os mesmos erros tipados (issue #10, #48).
|
|
144
|
+
*
|
|
145
|
+
* @param path - Caminho absoluto na API.
|
|
146
|
+
* @param params - Parâmetros de query opcionais.
|
|
147
|
+
* @param accept - Valor do header Accept (JSON, ou coringa para binário).
|
|
148
|
+
* @returns A resposta bem-sucedida e a URL já redigida (sem a api_key).
|
|
149
|
+
*/
|
|
150
|
+
const requestWithRetry = async (path, params, accept) => {
|
|
151
|
+
const url = buildUrl(path, params);
|
|
152
|
+
// URL segura para log/erro: a api_key nunca aparece em texto.
|
|
153
|
+
const safeUrl = redact(url.toString());
|
|
154
|
+
const headers = { Accept: accept };
|
|
155
|
+
if (!keyInQuery) {
|
|
156
|
+
headers[API_KEY_HEADER] = apiKey;
|
|
157
|
+
}
|
|
158
|
+
// Retry com backoff exponencial: só falhas transientes (429/5xx/rede) retentam.
|
|
159
|
+
// `attempt` é 0-based; a última tentativa (attempt === maxAttempts - 1) sempre
|
|
160
|
+
// propaga o erro tipado original, sem embrulhar (issue #10).
|
|
161
|
+
for (let attempt = 0;; attempt++) {
|
|
162
|
+
const lastAttempt = attempt >= maxAttempts - 1;
|
|
163
|
+
let response;
|
|
164
|
+
try {
|
|
165
|
+
response = await fetch(url, { method: 'GET', headers });
|
|
166
|
+
}
|
|
167
|
+
catch (cause) {
|
|
168
|
+
if (!lastAttempt) {
|
|
169
|
+
await sleep(backoffDelay(attempt, baseDelayMs));
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
173
|
+
throw new Error(`Falha de rede ao acessar ${safeUrl}: ${redact(reason)}`);
|
|
174
|
+
}
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
if (isRetryableStatus(response.status) && !lastAttempt) {
|
|
177
|
+
await sleep(backoffDelay(attempt, baseDelayMs));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const message = `GET ${safeUrl} respondeu ${response.status} ${redact(response.statusText)}`;
|
|
181
|
+
const error = httpErrorFor(message, response.status, safeUrl);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
return { response, safeUrl };
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
async get(path, params) {
|
|
189
|
+
const { response, safeUrl } = await requestWithRetry(path, params, 'application/json');
|
|
190
|
+
try {
|
|
191
|
+
return (await response.json());
|
|
192
|
+
}
|
|
193
|
+
catch (cause) {
|
|
194
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
195
|
+
throw new Error(`Resposta de ${safeUrl} não é JSON válido: ${redact(reason)}`);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
async getBinary(path, params) {
|
|
199
|
+
const { response, safeUrl } = await requestWithRetry(path, params, '*/*');
|
|
200
|
+
// Uma resposta 2xx sem corpo (ex.: 204) não tem o que baixar.
|
|
201
|
+
if (response.body === null) {
|
|
202
|
+
throw new Error(`Resposta binária de ${safeUrl} veio sem corpo.`);
|
|
203
|
+
}
|
|
204
|
+
return response.body;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const MODULE_NAME: "client";
|
|
2
|
+
export { createHttpClient, redactSecret, validateBaseUrl, type HttpClient, type HttpClientOptions, type Logger, type QueryParams, type RetryOptions, } from './http.js';
|
|
3
|
+
export { RedmineHttpError, RedmineAuthError, RedmineForbiddenError, RedmineNotFoundError, httpErrorFor, } from './errors.js';
|
|
4
|
+
export { getIssue, listIssues, type RedmineIssuePayload, type ListIssuesOptions, } from './issues.js';
|
|
5
|
+
export { searchIssues, SEARCH_MAX_LIMIT, type SearchIssuesOptions, type SearchIssuesPage, type RedmineSearchHit, } from './search.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const MODULE_NAME = 'client';
|
|
2
|
+
export { createHttpClient, redactSecret, validateBaseUrl, } from './http.js';
|
|
3
|
+
export { RedmineHttpError, RedmineAuthError, RedmineForbiddenError, RedmineNotFoundError, httpErrorFor, } from './errors.js';
|
|
4
|
+
export { getIssue, listIssues, } from './issues.js';
|
|
5
|
+
export { searchIssues, SEARCH_MAX_LIMIT, } from './search.js';
|