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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle JSON determinístico de uma issue normalizada (issue #15, ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* Converte uma {@link Issue} do contrato num corpo canônico byte-idêntico entre
|
|
5
|
+
* execuções do mesmo estado, próprio para consumo por LLMs. Três invariantes:
|
|
6
|
+
*
|
|
7
|
+
* 1. Determinismo: coleções recebem ordenação estável DECLARADA (journals por
|
|
8
|
+
* `(created_on, id)`; attachments/relations/custom_fields/children por `id`) e
|
|
9
|
+
* as chaves de objeto são ordenadas pelo serializador ({@link stableStringify}).
|
|
10
|
+
* 2. `generated_at` FORA do corpo canônico: vive no envelope separado, para que
|
|
11
|
+
* dois bundles do mesmo estado sejam idênticos. O chamador (CLI, #17) decide
|
|
12
|
+
* como unir corpo e envelope.
|
|
13
|
+
* 3. Anti prompt-injection: conteúdo textual derivado do Redmine (descrição, notas
|
|
14
|
+
* de journal, valores de custom field) é marcado `untrusted: true` (ADR-005).
|
|
15
|
+
*
|
|
16
|
+
* Follow-up da review da #11: refs degradadas — o `NULL_REF` congelado do
|
|
17
|
+
* normalize, com `id === 0` — NÃO são renderizadas como valores válidos; emitem
|
|
18
|
+
* `null` explícito, evitando que um placeholder vaze como referência real.
|
|
19
|
+
*/
|
|
20
|
+
import { stableStringify } from './stable-stringify.js';
|
|
21
|
+
/** Versão do schema do bundle — congelada em "1.0" para esta major. */
|
|
22
|
+
const SCHEMA_VERSION = '1.0';
|
|
23
|
+
/** Envolve um conteúdo não-confiável do Redmine com a marca `untrusted: true`. */
|
|
24
|
+
function untrusted(value) {
|
|
25
|
+
return { untrusted: true, value };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Renderiza uma ref obrigatória do contrato.
|
|
29
|
+
*
|
|
30
|
+
* Refs degradadas (`id === 0`, o `NULL_REF` do normalize) viram `null` — nunca
|
|
31
|
+
* são emitidas como `{ id: 0, name: '' }`, que aparentaria uma referência válida.
|
|
32
|
+
*
|
|
33
|
+
* @param ref - Ref do contrato (possivelmente o placeholder degradado).
|
|
34
|
+
* @returns `{ id, name }` para refs reais; `null` para o placeholder.
|
|
35
|
+
*/
|
|
36
|
+
function renderRef(ref) {
|
|
37
|
+
if (ref.id === 0)
|
|
38
|
+
return null;
|
|
39
|
+
return { id: ref.id, name: ref.name };
|
|
40
|
+
}
|
|
41
|
+
/** Renderiza uma ref opcional; ausência vira `null` (mesmo canal que degradação). */
|
|
42
|
+
function renderOptionalRef(ref) {
|
|
43
|
+
return ref === undefined ? null : renderRef(ref);
|
|
44
|
+
}
|
|
45
|
+
/** Detalhe bruto de journal — histórico estruturado, repassado sem interpretação. */
|
|
46
|
+
function renderDetail(detail) {
|
|
47
|
+
const out = {
|
|
48
|
+
property: detail.property,
|
|
49
|
+
// `name` fica estrutural: vocabulário fixo em property=attr e id em
|
|
50
|
+
// property=cf — é chave programática do status history.
|
|
51
|
+
name: detail.name,
|
|
52
|
+
};
|
|
53
|
+
// old/new_value carregam texto derivado (ex.: descrição inteira em
|
|
54
|
+
// details de "description") — mesma categoria de confiança de notes.
|
|
55
|
+
if ('old_value' in detail) {
|
|
56
|
+
out.old_value = detail.old_value === null || detail.old_value === undefined ? null : untrusted(detail.old_value);
|
|
57
|
+
}
|
|
58
|
+
if ('new_value' in detail) {
|
|
59
|
+
out.new_value = detail.new_value === null || detail.new_value === undefined ? null : untrusted(detail.new_value);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** Renderiza um journal; a nota livre (se houver) é marcada `untrusted`. */
|
|
64
|
+
function renderJournal(journal) {
|
|
65
|
+
const out = {
|
|
66
|
+
id: journal.id,
|
|
67
|
+
created_on: journal.created_on,
|
|
68
|
+
user: renderOptionalRef(journal.user),
|
|
69
|
+
details: journal.details.map(renderDetail),
|
|
70
|
+
};
|
|
71
|
+
if (journal.notes !== undefined)
|
|
72
|
+
out.notes = untrusted(journal.notes);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** Renderiza um custom field; o `value` (texto do Redmine) é marcado `untrusted`. */
|
|
76
|
+
function renderCustomField(field) {
|
|
77
|
+
const out = {
|
|
78
|
+
id: field.id,
|
|
79
|
+
name: field.name,
|
|
80
|
+
value: untrusted(field.value),
|
|
81
|
+
raw_value: field.raw_value,
|
|
82
|
+
};
|
|
83
|
+
if (field.field_format !== undefined)
|
|
84
|
+
out.field_format = field.field_format;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Renderiza um artefato derivado da extração (M4-08) — ex.: o keyframe de vídeo.
|
|
89
|
+
* Emite APENAS a REFERÊNCIA (tipo + caminho no cache + mime); o binário NUNCA é
|
|
90
|
+
* embutido (ADR-002: o MCP é read-only e textual). O `path`/`kind`/`mime` são dados
|
|
91
|
+
* NOSSOS (não derivados do Redmine), logo sem marcação `untrusted`.
|
|
92
|
+
*
|
|
93
|
+
* @param artifact - Artefato produzido pela extração.
|
|
94
|
+
* @returns Objeto JSON `{ kind, path, mime? }`.
|
|
95
|
+
*/
|
|
96
|
+
function renderArtifact(artifact) {
|
|
97
|
+
const out = { kind: artifact.kind, path: artifact.path };
|
|
98
|
+
if (artifact.mime !== undefined)
|
|
99
|
+
out.mime = artifact.mime;
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Renderiza o resultado da extração de um anexo (M3-10). `status` sempre presente;
|
|
104
|
+
* `text` só quando há texto, marcado `untrusted` (conteúdo derivado do anexo);
|
|
105
|
+
* `reason` só quando o resultado (skip/falha/unsupported) o carrega em metadata;
|
|
106
|
+
* `artifacts` (M4-08) referencia derivados no cache (ex.: keyframe), sem embutir.
|
|
107
|
+
*
|
|
108
|
+
* @param result - Resultado de uma extração de anexo.
|
|
109
|
+
* @returns Objeto JSON `{ status, text?, reason?, hint?, artifacts? }`.
|
|
110
|
+
*/
|
|
111
|
+
function renderExtraction(result) {
|
|
112
|
+
const out = { status: result.status };
|
|
113
|
+
if (typeof result.text === 'string' && result.text !== '') {
|
|
114
|
+
out.text = untrusted(result.text);
|
|
115
|
+
}
|
|
116
|
+
const reason = result.metadata?.['reason'];
|
|
117
|
+
if (typeof reason === 'string')
|
|
118
|
+
out.reason = reason;
|
|
119
|
+
// Paridade com o MD (review #141): o hint (texto NOSSO, ex.: instrução de
|
|
120
|
+
// instalação) é a parte acionável para o consumidor do JSON (MCP/LLM).
|
|
121
|
+
const hint = result.metadata?.['hint'];
|
|
122
|
+
if (typeof hint === 'string')
|
|
123
|
+
out.hint = hint;
|
|
124
|
+
if (result.artifacts !== undefined && result.artifacts.length > 0) {
|
|
125
|
+
out.artifacts = result.artifacts.map(renderArtifact);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** Renderiza um anexo (metadados; conteúdo binário é extraído fora do bundle). */
|
|
130
|
+
function renderAttachment(att, extractions) {
|
|
131
|
+
const out = {
|
|
132
|
+
id: att.id,
|
|
133
|
+
filename: att.filename,
|
|
134
|
+
filesize: att.filesize,
|
|
135
|
+
created_on: att.created_on,
|
|
136
|
+
content_url: att.content_url,
|
|
137
|
+
};
|
|
138
|
+
if (att.content_type !== undefined)
|
|
139
|
+
out.content_type = att.content_type;
|
|
140
|
+
if (att.description !== undefined)
|
|
141
|
+
out.description = untrusted(att.description);
|
|
142
|
+
if (att.author !== undefined)
|
|
143
|
+
out.author = renderOptionalRef(att.author);
|
|
144
|
+
if (att.digest !== undefined)
|
|
145
|
+
out.digest = att.digest;
|
|
146
|
+
const extraction = extractions?.get(att.id);
|
|
147
|
+
if (extraction !== undefined)
|
|
148
|
+
out.extraction = renderExtraction(extraction);
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** Renderiza uma relação entre issues. */
|
|
152
|
+
function renderRelation(rel) {
|
|
153
|
+
return {
|
|
154
|
+
id: rel.id,
|
|
155
|
+
issue_id: rel.issue_id,
|
|
156
|
+
issue_to_id: rel.issue_to_id,
|
|
157
|
+
relation_type: rel.relation_type,
|
|
158
|
+
delay: rel.delay ?? null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Renderiza uma issue-filha. */
|
|
162
|
+
function renderChild(child) {
|
|
163
|
+
const out = { id: child.id };
|
|
164
|
+
if (child.tracker !== undefined)
|
|
165
|
+
out.tracker = renderOptionalRef(child.tracker);
|
|
166
|
+
if (child.subject !== undefined)
|
|
167
|
+
out.subject = untrusted(child.subject);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Comparador estável de journals: `created_on` e, em empate, `id`.
|
|
172
|
+
*
|
|
173
|
+
* Exportado para que o bundle Markdown (#16) reutilize EXATAMENTE a mesma
|
|
174
|
+
* ordenação cronológica do JSON, sem duplicar a regra de desempate.
|
|
175
|
+
*/
|
|
176
|
+
export function compareJournals(a, b) {
|
|
177
|
+
if (a.created_on < b.created_on)
|
|
178
|
+
return -1;
|
|
179
|
+
if (a.created_on > b.created_on)
|
|
180
|
+
return 1;
|
|
181
|
+
return a.id - b.id;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Ordena por `id` sem mutar o array de entrada.
|
|
185
|
+
*
|
|
186
|
+
* Exportado para reuso pelo bundle Markdown (#16): attachments, relations,
|
|
187
|
+
* custom_fields e children compartilham a MESMA ordenação estável do JSON.
|
|
188
|
+
*/
|
|
189
|
+
export function byId(items) {
|
|
190
|
+
return [...items].sort((a, b) => a.id - b.id);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Monta o corpo da issue já ordenado e com marcações `untrusted`.
|
|
194
|
+
*
|
|
195
|
+
* @param issue - Issue normalizada do contrato.
|
|
196
|
+
* @param extractions - Extrações por anexo (M3-10); `undefined` = sem extração.
|
|
197
|
+
* @returns Objeto JSON pronto para serialização estável.
|
|
198
|
+
*/
|
|
199
|
+
function renderIssue(issue, extractions) {
|
|
200
|
+
const out = {
|
|
201
|
+
id: issue.id,
|
|
202
|
+
subject: untrusted(issue.subject),
|
|
203
|
+
description: issue.description === undefined ? null : untrusted(issue.description),
|
|
204
|
+
project: renderRef(issue.project),
|
|
205
|
+
tracker: renderRef(issue.tracker),
|
|
206
|
+
status: renderRef(issue.status),
|
|
207
|
+
priority: renderRef(issue.priority),
|
|
208
|
+
author: renderRef(issue.author),
|
|
209
|
+
created_on: issue.created_on,
|
|
210
|
+
updated_on: issue.updated_on,
|
|
211
|
+
custom_fields: byId(issue.custom_fields).map(renderCustomField),
|
|
212
|
+
journals: [...issue.journals].sort(compareJournals).map(renderJournal),
|
|
213
|
+
attachments: byId(issue.attachments).map((att) => renderAttachment(att, extractions)),
|
|
214
|
+
relations: byId(issue.relations).map(renderRelation),
|
|
215
|
+
children: byId(issue.children).map(renderChild),
|
|
216
|
+
};
|
|
217
|
+
if (issue.assigned_to !== undefined)
|
|
218
|
+
out.assigned_to = renderOptionalRef(issue.assigned_to);
|
|
219
|
+
if (issue.done_ratio !== undefined)
|
|
220
|
+
out.done_ratio = issue.done_ratio;
|
|
221
|
+
if (issue.start_date !== undefined)
|
|
222
|
+
out.start_date = issue.start_date;
|
|
223
|
+
if (issue.due_date !== undefined)
|
|
224
|
+
out.due_date = issue.due_date;
|
|
225
|
+
if (issue.parent !== undefined)
|
|
226
|
+
out.parent = { id: issue.parent.id };
|
|
227
|
+
if (issue.watchers !== undefined)
|
|
228
|
+
out.watchers = byId(issue.watchers).map(renderRef);
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Empacota uma issue normalizada num bundle JSON determinístico.
|
|
233
|
+
*
|
|
234
|
+
* O corpo canônico é byte-idêntico entre execuções do mesmo estado (ordenação
|
|
235
|
+
* estável + serializador de chaves ordenadas). `generated_at` fica no envelope,
|
|
236
|
+
* fora do corpo, preservando essa propriedade.
|
|
237
|
+
*
|
|
238
|
+
* @param issue - Issue normalizada (ver `src/normalize`).
|
|
239
|
+
* @param meta - Metadados do empacotamento (base URL, versão, timestamp opcional).
|
|
240
|
+
* @returns Corpo canônico serializado e envelope com `generated_at` + `source`.
|
|
241
|
+
* @example
|
|
242
|
+
* const { canonical, envelope } = buildJsonBundle(issue, {
|
|
243
|
+
* baseUrl: 'https://redmine.example',
|
|
244
|
+
* toolVersion: TOOL_VERSION,
|
|
245
|
+
* });
|
|
246
|
+
*/
|
|
247
|
+
export function buildJsonBundle(issue, meta) {
|
|
248
|
+
const source = {
|
|
249
|
+
base_url: meta.baseUrl,
|
|
250
|
+
issue_id: issue.id,
|
|
251
|
+
issue_updated_on: issue.updated_on,
|
|
252
|
+
};
|
|
253
|
+
const body = {
|
|
254
|
+
schema_version: SCHEMA_VERSION,
|
|
255
|
+
tool_version: meta.toolVersion,
|
|
256
|
+
source: { ...source },
|
|
257
|
+
issue: renderIssue(issue, meta.extractions),
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
canonical: stableStringify(body),
|
|
261
|
+
envelope: {
|
|
262
|
+
generated_at: meta.generatedAt ?? new Date().toISOString(),
|
|
263
|
+
source,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle Markdown determinístico de uma issue normalizada (issue #16, ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* Renderiza uma {@link Issue} num documento Markdown legível por humanos e por
|
|
5
|
+
* LLMs, mantendo as MESMAS invariantes do bundle JSON (#15):
|
|
6
|
+
*
|
|
7
|
+
* 1. Determinismo: coleções recebem a ordenação estável DECLARADA — reutilizada
|
|
8
|
+
* de `./json.ts` ({@link compareJournals}, {@link byId}) sem duplicar a regra.
|
|
9
|
+
* Dois bundles do mesmo estado são byte-idênticos; não há `generated_at` no
|
|
10
|
+
* corpo (o timestamp vive apenas no envelope do JSON, #15).
|
|
11
|
+
* 2. Anti prompt-injection: TODO conteúdo textual derivado do Redmine (subject,
|
|
12
|
+
* descrição, notas de journal, valores de custom field, nomes e descrições de
|
|
13
|
+
* anexo, subjects de filhos) é isolado dentro de fences
|
|
14
|
+
* `<untrusted-content>...</untrusted-content>`. Este é EXATAMENTE o conjunto
|
|
15
|
+
* marcado `untrusted: true` no JSON, acrescido do nome do anexo (M1-10).
|
|
16
|
+
* Metadados estruturais (ids, datas, nomes de campos padrão, URLs) ficam fora.
|
|
17
|
+
* 3. Refs degradadas: o `NULL_REF` do normalize (`id === 0`) é renderizado como
|
|
18
|
+
* "(desconhecido)" — nunca como um nome vazio silencioso.
|
|
19
|
+
*
|
|
20
|
+
* No M1 os anexos são texto-only: referenciados pela URL de download do Redmine,
|
|
21
|
+
* sem baixar o binário.
|
|
22
|
+
*/
|
|
23
|
+
import type { Issue } from '../contract.js';
|
|
24
|
+
import { type ExtractionMap } from './json.js';
|
|
25
|
+
/** Metadados de empacotamento do bundle Markdown (sem timestamp — determinismo). */
|
|
26
|
+
export interface MarkdownBundleMeta {
|
|
27
|
+
/** Base URL do Redmine de origem — compõe as URLs de download dos anexos. */
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
/** Versão da ferramenta (`TOOL_VERSION`), registrada no rodapé de proveniência. */
|
|
30
|
+
toolVersion: string;
|
|
31
|
+
/**
|
|
32
|
+
* Extrações de anexos (M3-10). Quando presente, cada anexo com resultado ganha
|
|
33
|
+
* a seção "Texto extraído" — o texto de OCR dentro de `<untrusted-content>`.
|
|
34
|
+
*/
|
|
35
|
+
extractions?: ExtractionMap;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Isola conteúdo não confiável num bloco `<untrusted-content>` de várias linhas.
|
|
39
|
+
*
|
|
40
|
+
* Exportada para reutilização por outras superfícies do core (ex.: a tool MCP
|
|
41
|
+
* `get_attachment_text`, M3-13) que devolvem texto multi-linha derivado do
|
|
42
|
+
* Redmine e precisam da MESMA marcação anti prompt-injection, sem duplicá-la.
|
|
43
|
+
*
|
|
44
|
+
* @param text - Conteúdo derivado do Redmine.
|
|
45
|
+
* @returns Bloco fence pronto para inserção como seção.
|
|
46
|
+
*/
|
|
47
|
+
export declare function fenceBlock(text: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Isola conteúdo não confiável numa fence inline (uma linha).
|
|
50
|
+
*
|
|
51
|
+
* Exportada para ser reutilizada por outros renderizadores do bundle (ex.: a
|
|
52
|
+
* lista compacta de resultados de busca, M1-13) sem duplicar a marcação
|
|
53
|
+
* anti prompt-injection.
|
|
54
|
+
*
|
|
55
|
+
* @param text - Conteúdo derivado do Redmine.
|
|
56
|
+
* @returns Fence inline `<untrusted-content>...</untrusted-content>`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function fenceInline(text: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Empacota uma issue normalizada num documento Markdown determinístico.
|
|
61
|
+
*
|
|
62
|
+
* O corpo é byte-idêntico entre execuções do mesmo estado (ordenação estável
|
|
63
|
+
* reutilizada do JSON + ausência de `generated_at`). Todo conteúdo textual
|
|
64
|
+
* derivado do Redmine é isolado em fences `<untrusted-content>`.
|
|
65
|
+
*
|
|
66
|
+
* @param issue - Issue normalizada (ver `src/normalize`).
|
|
67
|
+
* @param meta - Base URL do Redmine e versão da ferramenta.
|
|
68
|
+
* @returns Documento Markdown completo, terminado por uma quebra de linha.
|
|
69
|
+
* @example
|
|
70
|
+
* const md = buildMarkdownBundle(issue, {
|
|
71
|
+
* baseUrl: 'https://redmine.example',
|
|
72
|
+
* toolVersion: TOOL_VERSION,
|
|
73
|
+
* });
|
|
74
|
+
*/
|
|
75
|
+
export declare function buildMarkdownBundle(issue: Issue, meta: MarkdownBundleMeta): string;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle Markdown determinístico de uma issue normalizada (issue #16, ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* Renderiza uma {@link Issue} num documento Markdown legível por humanos e por
|
|
5
|
+
* LLMs, mantendo as MESMAS invariantes do bundle JSON (#15):
|
|
6
|
+
*
|
|
7
|
+
* 1. Determinismo: coleções recebem a ordenação estável DECLARADA — reutilizada
|
|
8
|
+
* de `./json.ts` ({@link compareJournals}, {@link byId}) sem duplicar a regra.
|
|
9
|
+
* Dois bundles do mesmo estado são byte-idênticos; não há `generated_at` no
|
|
10
|
+
* corpo (o timestamp vive apenas no envelope do JSON, #15).
|
|
11
|
+
* 2. Anti prompt-injection: TODO conteúdo textual derivado do Redmine (subject,
|
|
12
|
+
* descrição, notas de journal, valores de custom field, nomes e descrições de
|
|
13
|
+
* anexo, subjects de filhos) é isolado dentro de fences
|
|
14
|
+
* `<untrusted-content>...</untrusted-content>`. Este é EXATAMENTE o conjunto
|
|
15
|
+
* marcado `untrusted: true` no JSON, acrescido do nome do anexo (M1-10).
|
|
16
|
+
* Metadados estruturais (ids, datas, nomes de campos padrão, URLs) ficam fora.
|
|
17
|
+
* 3. Refs degradadas: o `NULL_REF` do normalize (`id === 0`) é renderizado como
|
|
18
|
+
* "(desconhecido)" — nunca como um nome vazio silencioso.
|
|
19
|
+
*
|
|
20
|
+
* No M1 os anexos são texto-only: referenciados pela URL de download do Redmine,
|
|
21
|
+
* sem baixar o binário.
|
|
22
|
+
*/
|
|
23
|
+
import { byId, compareJournals } from './json.js';
|
|
24
|
+
/** Placeholder para ref degradada (id 0) — nunca um nome vazio silencioso. */
|
|
25
|
+
const UNKNOWN_REF = '(desconhecido)';
|
|
26
|
+
/** Placeholder para ref opcional ausente (distinto de degradada). */
|
|
27
|
+
const ABSENT_REF = '(nenhum)';
|
|
28
|
+
/**
|
|
29
|
+
* Neutraliza tentativas de fuga da fence embutidas no conteúdo não confiável.
|
|
30
|
+
*
|
|
31
|
+
* Reason: um valor do Redmine pode conter literalmente `</untrusted-content>`
|
|
32
|
+
* para "fechar" a fence e injetar instruções fora dela. Inserimos um espaço de
|
|
33
|
+
* largura zero após o `<` de qualquer tag de fence, preservando o texto visível
|
|
34
|
+
* mas quebrando o token — de forma determinística.
|
|
35
|
+
*
|
|
36
|
+
* @param text - Conteúdo bruto derivado do Redmine.
|
|
37
|
+
* @returns Texto com tokens de fence defangados.
|
|
38
|
+
*/
|
|
39
|
+
function neutralizeFence(text) {
|
|
40
|
+
// Tolerante a espaços internos: "< /untrusted-content >" também fecharia o
|
|
41
|
+
// fence aos olhos de um LLM — normaliza qualquer variante.
|
|
42
|
+
return text.replace(/<\s*(\/?)\s*untrusted-content\s*>/gi, '<$1untrusted-content>');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Isola conteúdo não confiável num bloco `<untrusted-content>` de várias linhas.
|
|
46
|
+
*
|
|
47
|
+
* Exportada para reutilização por outras superfícies do core (ex.: a tool MCP
|
|
48
|
+
* `get_attachment_text`, M3-13) que devolvem texto multi-linha derivado do
|
|
49
|
+
* Redmine e precisam da MESMA marcação anti prompt-injection, sem duplicá-la.
|
|
50
|
+
*
|
|
51
|
+
* @param text - Conteúdo derivado do Redmine.
|
|
52
|
+
* @returns Bloco fence pronto para inserção como seção.
|
|
53
|
+
*/
|
|
54
|
+
export function fenceBlock(text) {
|
|
55
|
+
return `<untrusted-content>\n${neutralizeFence(text)}\n</untrusted-content>`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Isola conteúdo não confiável numa fence inline (uma linha).
|
|
59
|
+
*
|
|
60
|
+
* Exportada para ser reutilizada por outros renderizadores do bundle (ex.: a
|
|
61
|
+
* lista compacta de resultados de busca, M1-13) sem duplicar a marcação
|
|
62
|
+
* anti prompt-injection.
|
|
63
|
+
*
|
|
64
|
+
* @param text - Conteúdo derivado do Redmine.
|
|
65
|
+
* @returns Fence inline `<untrusted-content>...</untrusted-content>`.
|
|
66
|
+
*/
|
|
67
|
+
export function fenceInline(text) {
|
|
68
|
+
return `<untrusted-content>${neutralizeFence(text)}</untrusted-content>`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Nome legível de uma ref obrigatória, tratando degradação.
|
|
72
|
+
*
|
|
73
|
+
* @param ref - Ref do contrato (possivelmente o placeholder `id === 0`).
|
|
74
|
+
* @returns Nome real ou "(desconhecido)" para o placeholder degradado.
|
|
75
|
+
*/
|
|
76
|
+
function refName(ref) {
|
|
77
|
+
return ref.id === 0 ? UNKNOWN_REF : ref.name;
|
|
78
|
+
}
|
|
79
|
+
/** Nome de uma ref opcional; ausência → "(nenhum)", degradada → "(desconhecido)". */
|
|
80
|
+
function optionalRefName(ref) {
|
|
81
|
+
return ref === undefined ? ABSENT_REF : refName(ref);
|
|
82
|
+
}
|
|
83
|
+
/** Achata o valor de um custom field (multi-valor → lista separada por vírgula). */
|
|
84
|
+
function customFieldText(value) {
|
|
85
|
+
if (value === null)
|
|
86
|
+
return null;
|
|
87
|
+
return Array.isArray(value) ? value.join(', ') : value;
|
|
88
|
+
}
|
|
89
|
+
/** Cabeçalho + assunto (fenced) + metadados estruturais da issue. */
|
|
90
|
+
function renderHeader(issue) {
|
|
91
|
+
const lines = [`# Issue #${issue.id}`, '', '## Assunto', '', fenceBlock(issue.subject), ''];
|
|
92
|
+
lines.push('## Metadados', '');
|
|
93
|
+
lines.push(`- **Projeto:** ${refName(issue.project)}`);
|
|
94
|
+
lines.push(`- **Tracker:** ${refName(issue.tracker)}`);
|
|
95
|
+
lines.push(`- **Status:** ${refName(issue.status)}`);
|
|
96
|
+
lines.push(`- **Prioridade:** ${refName(issue.priority)}`);
|
|
97
|
+
lines.push(`- **Autor:** ${refName(issue.author)}`);
|
|
98
|
+
lines.push(`- **Responsável:** ${optionalRefName(issue.assigned_to)}`);
|
|
99
|
+
lines.push(`- **Criada em:** ${issue.created_on}`);
|
|
100
|
+
lines.push(`- **Atualizada em:** ${issue.updated_on}`);
|
|
101
|
+
if (issue.done_ratio !== undefined)
|
|
102
|
+
lines.push(`- **Progresso:** ${issue.done_ratio}%`);
|
|
103
|
+
if (issue.start_date !== undefined)
|
|
104
|
+
lines.push(`- **Início:** ${issue.start_date}`);
|
|
105
|
+
if (issue.due_date !== undefined)
|
|
106
|
+
lines.push(`- **Prazo:** ${issue.due_date}`);
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
109
|
+
/** Seção de descrição (fenced) — placeholder estrutural quando ausente. */
|
|
110
|
+
function renderDescription(issue) {
|
|
111
|
+
const body = issue.description === undefined ? '_(sem descrição)_' : fenceBlock(issue.description);
|
|
112
|
+
return `## Descrição\n\n${body}`;
|
|
113
|
+
}
|
|
114
|
+
/** Seção de custom fields — nome do campo (metadado) + valor (fenced). */
|
|
115
|
+
function renderCustomFields(issue) {
|
|
116
|
+
if (issue.custom_fields.length === 0)
|
|
117
|
+
return '## Custom Fields\n\n_(nenhum)_';
|
|
118
|
+
const rows = byId(issue.custom_fields).map((field) => {
|
|
119
|
+
const text = customFieldText(field.value);
|
|
120
|
+
const value = text === null ? '_(vazio)_' : fenceInline(text);
|
|
121
|
+
// O nome do custom field é definido pelo admin da instância — derivado.
|
|
122
|
+
return `- **${fenceInline(field.name)}:** ${value}`;
|
|
123
|
+
});
|
|
124
|
+
return ['## Custom Fields', '', ...rows].join('\n');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
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
|
+
*/
|
|
131
|
+
function renderJournalDetails(journal) {
|
|
132
|
+
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}`;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** Renderiza uma entrada de journal: cabeçalho estrutural + nota (fenced). */
|
|
139
|
+
function renderJournal(journal) {
|
|
140
|
+
const lines = [`### Journal #${journal.id} — ${journal.created_on} — ${optionalRefName(journal.user)}`, ''];
|
|
141
|
+
const details = renderJournalDetails(journal);
|
|
142
|
+
if (details.length > 0)
|
|
143
|
+
lines.push('Alterações:', ...details, '');
|
|
144
|
+
if (journal.notes !== undefined)
|
|
145
|
+
lines.push('Nota:', fenceBlock(journal.notes), '');
|
|
146
|
+
if (details.length === 0 && journal.notes === undefined)
|
|
147
|
+
lines.push('_(sem alterações ou notas)_', '');
|
|
148
|
+
return lines.join('\n').trimEnd();
|
|
149
|
+
}
|
|
150
|
+
/** Seção de histórico — journals em ordem cronológica estável (created_on, id). */
|
|
151
|
+
function renderJournals(issue) {
|
|
152
|
+
if (issue.journals.length === 0)
|
|
153
|
+
return '## Histórico\n\n_(nenhum)_';
|
|
154
|
+
const ordered = [...issue.journals].sort(compareJournals).map(renderJournal);
|
|
155
|
+
return ['## Histórico', ...ordered].join('\n\n');
|
|
156
|
+
}
|
|
157
|
+
/** Seção de relações — tipo + issue alvo (tudo estrutural). */
|
|
158
|
+
function renderRelations(issue) {
|
|
159
|
+
if (issue.relations.length === 0)
|
|
160
|
+
return '## Relações\n\n_(nenhuma)_';
|
|
161
|
+
const rows = byId(issue.relations).map((rel) => {
|
|
162
|
+
const delay = rel.delay === undefined || rel.delay === null ? '' : ` (atraso: ${rel.delay}d)`;
|
|
163
|
+
return `- **${rel.relation_type}** → issue #${rel.issue_to_id}${delay}`;
|
|
164
|
+
});
|
|
165
|
+
return ['## Relações', '', ...rows].join('\n');
|
|
166
|
+
}
|
|
167
|
+
/** Seção do pai — referência estrutural por número de issue. */
|
|
168
|
+
function renderParent(issue) {
|
|
169
|
+
const body = issue.parent === undefined ? '_(nenhuma)_' : `- issue #${issue.parent.id}`;
|
|
170
|
+
return `## Issue Pai\n\n${body}`;
|
|
171
|
+
}
|
|
172
|
+
/** Renderiza uma issue-filha: id/tracker estruturais + subject (fenced). */
|
|
173
|
+
function renderChild(child) {
|
|
174
|
+
const tracker = child.tracker === undefined ? '' : ` [${refName(child.tracker)}]`;
|
|
175
|
+
const subject = child.subject === undefined ? '' : ` ${fenceInline(child.subject)}`;
|
|
176
|
+
return `- issue #${child.id}${tracker}${subject}`;
|
|
177
|
+
}
|
|
178
|
+
/** Seção de sub-issues — filhos ordenados por id. */
|
|
179
|
+
function renderChildren(issue) {
|
|
180
|
+
if (issue.children.length === 0)
|
|
181
|
+
return '## Sub-issues\n\n_(nenhuma)_';
|
|
182
|
+
const rows = byId(issue.children).map(renderChild);
|
|
183
|
+
return ['## Sub-issues', '', ...rows].join('\n');
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Renderiza as linhas de "Artefatos" de uma extração (M4-08) — ex.: o keyframe de
|
|
187
|
+
* vídeo. Emite só a REFERÊNCIA (tipo + caminho no cache local); o binário NUNCA é
|
|
188
|
+
* embutido (ADR-002). O caminho é dado NOSSO (deriva de id/digest hex do anexo),
|
|
189
|
+
* portanto fora de `<untrusted-content>`. A URL do Redmine do anexo já consta no
|
|
190
|
+
* bloco do anexo (linha "URL"), completando a referência path-de-cache + URL.
|
|
191
|
+
*
|
|
192
|
+
* @param artifacts - Artefatos derivados da extração.
|
|
193
|
+
* @returns Linhas Markdown a concatenar ao bloco do anexo (vazio se não houver).
|
|
194
|
+
*/
|
|
195
|
+
function renderArtifacts(artifacts) {
|
|
196
|
+
if (artifacts === undefined || artifacts.length === 0)
|
|
197
|
+
return [];
|
|
198
|
+
const lines = [' - Artefatos:'];
|
|
199
|
+
for (const art of artifacts) {
|
|
200
|
+
const mime = art.mime === undefined ? '' : ` (${art.mime})`;
|
|
201
|
+
lines.push(` - ${art.kind}: ${art.path}${mime}`);
|
|
202
|
+
}
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Renderiza a seção "Texto extraído" de um anexo (M3-10). O texto de OCR é
|
|
207
|
+
* conteúdo DERIVADO do anexo, logo isolado dentro de `<untrusted-content>`. Quando
|
|
208
|
+
* não há texto (skip/falha/unsupported), mostra `status` + `reason`/`hint` — assim
|
|
209
|
+
* o bundle continua saindo e, no caso de tesseract ausente, DIZ como instalar.
|
|
210
|
+
* Artefatos derivados (keyframe, M4-08) são REFERENCIADOS ao final, sem embutir.
|
|
211
|
+
*
|
|
212
|
+
* @param result - Resultado da extração do anexo.
|
|
213
|
+
* @returns Linhas Markdown da seção (para concatenar ao bloco do anexo).
|
|
214
|
+
*/
|
|
215
|
+
function renderExtraction(result) {
|
|
216
|
+
const artifacts = renderArtifacts(result.artifacts);
|
|
217
|
+
if (typeof result.text === 'string' && result.text.trim() !== '') {
|
|
218
|
+
return [` - Texto extraído (${result.status}):`, fenceBlock(result.text), ...artifacts];
|
|
219
|
+
}
|
|
220
|
+
const lines = [` - Texto extraído (${result.status}):`];
|
|
221
|
+
const reason = result.metadata?.['reason'];
|
|
222
|
+
const hint = result.metadata?.['hint'];
|
|
223
|
+
if (typeof reason === 'string')
|
|
224
|
+
lines.push(` - Motivo: ${reason}`);
|
|
225
|
+
// `hint` é texto NOSSO (não do Redmine) — ex.: instruções de instalação; fica
|
|
226
|
+
// fora da fence untrusted por ser confiável.
|
|
227
|
+
if (typeof hint === 'string')
|
|
228
|
+
lines.push(` - ${hint}`);
|
|
229
|
+
if (typeof reason !== 'string' && typeof hint !== 'string' && artifacts.length === 0) {
|
|
230
|
+
lines.push(' - _(sem texto)_');
|
|
231
|
+
}
|
|
232
|
+
return [...lines, ...artifacts];
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Renderiza um anexo: metadados estruturais + URL de download do Redmine.
|
|
236
|
+
*
|
|
237
|
+
* O nome e a descrição (conteúdo derivado) vão em fence; a URL usa o filename
|
|
238
|
+
* percent-encoded como componente de caminho, mantendo-a válida e imune a fuga.
|
|
239
|
+
* Quando há extração (M3-10), acrescenta a seção "Texto extraído".
|
|
240
|
+
*
|
|
241
|
+
* @param att - Anexo normalizado.
|
|
242
|
+
* @param baseUrl - Base URL do Redmine.
|
|
243
|
+
* @param extractions - Extrações por anexo; `undefined` = sem extração.
|
|
244
|
+
* @returns Bloco Markdown do anexo.
|
|
245
|
+
*/
|
|
246
|
+
function renderAttachment(att, baseUrl, extractions) {
|
|
247
|
+
const url = `${baseUrl}/attachments/download/${att.id}/${encodeURIComponent(att.filename)}`;
|
|
248
|
+
const lines = [`- **Anexo #${att.id}** — ${att.filesize} bytes`];
|
|
249
|
+
lines.push(` - Nome: ${fenceInline(att.filename)}`);
|
|
250
|
+
lines.push(` - URL: ${url}`);
|
|
251
|
+
if (att.description !== undefined)
|
|
252
|
+
lines.push(` - Descrição: ${fenceInline(att.description)}`);
|
|
253
|
+
const extraction = extractions?.get(att.id);
|
|
254
|
+
if (extraction !== undefined)
|
|
255
|
+
lines.push(...renderExtraction(extraction));
|
|
256
|
+
return lines.join('\n');
|
|
257
|
+
}
|
|
258
|
+
/** Seção de anexos — referenciados por URL do Redmine, ordenados por id. */
|
|
259
|
+
function renderAttachments(issue, baseUrl, extractions) {
|
|
260
|
+
if (issue.attachments.length === 0)
|
|
261
|
+
return '## Anexos\n\n_(nenhum)_';
|
|
262
|
+
const rows = byId(issue.attachments).map((att) => renderAttachment(att, baseUrl, extractions));
|
|
263
|
+
return ['## Anexos', '', ...rows].join('\n');
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Empacota uma issue normalizada num documento Markdown determinístico.
|
|
267
|
+
*
|
|
268
|
+
* O corpo é byte-idêntico entre execuções do mesmo estado (ordenação estável
|
|
269
|
+
* reutilizada do JSON + ausência de `generated_at`). Todo conteúdo textual
|
|
270
|
+
* derivado do Redmine é isolado em fences `<untrusted-content>`.
|
|
271
|
+
*
|
|
272
|
+
* @param issue - Issue normalizada (ver `src/normalize`).
|
|
273
|
+
* @param meta - Base URL do Redmine e versão da ferramenta.
|
|
274
|
+
* @returns Documento Markdown completo, terminado por uma quebra de linha.
|
|
275
|
+
* @example
|
|
276
|
+
* const md = buildMarkdownBundle(issue, {
|
|
277
|
+
* baseUrl: 'https://redmine.example',
|
|
278
|
+
* toolVersion: TOOL_VERSION,
|
|
279
|
+
* });
|
|
280
|
+
*/
|
|
281
|
+
export function buildMarkdownBundle(issue, meta) {
|
|
282
|
+
const sections = [
|
|
283
|
+
renderHeader(issue),
|
|
284
|
+
renderDescription(issue),
|
|
285
|
+
renderCustomFields(issue),
|
|
286
|
+
renderJournals(issue),
|
|
287
|
+
renderRelations(issue),
|
|
288
|
+
renderParent(issue),
|
|
289
|
+
renderChildren(issue),
|
|
290
|
+
renderAttachments(issue, meta.baseUrl, meta.extractions),
|
|
291
|
+
`---\n\n_Empacotado por redmine-context v${meta.toolVersion}._`,
|
|
292
|
+
];
|
|
293
|
+
return `${sections.join('\n\n')}\n`;
|
|
294
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lista compacta em Markdown dos resultados da tool `search_issues` (M1-13).
|
|
3
|
+
*
|
|
4
|
+
* Diferente do bundle completo de uma issue (#16), aqui cada item é UMA linha:
|
|
5
|
+
* id + status + responsável (metadados estruturais, fora da fence) e o assunto
|
|
6
|
+
* isolado numa fence `<untrusted-content>` — reutilizando {@link fenceInline}
|
|
7
|
+
* para manter a MESMA marcação anti prompt-injection do bundle.
|
|
8
|
+
*
|
|
9
|
+
* Determinístico: nenhum timestamp; a ordem é a recebida do chamador (que já
|
|
10
|
+
* reflete a ordenação do Redmine). Avisos de degradação (ex.: `/search`
|
|
11
|
+
* indisponível) são renderizados no corpo, atendendo ao "aviso no payload".
|
|
12
|
+
*/
|
|
13
|
+
/** Item compacto de um resultado de busca (status/responsável já resolvidos). */
|
|
14
|
+
export interface SearchListItem {
|
|
15
|
+
/** Id da issue. */
|
|
16
|
+
id: number;
|
|
17
|
+
/** Assunto derivado do Redmine — renderizado dentro da fence untrusted. */
|
|
18
|
+
subject: string | null;
|
|
19
|
+
/** Nome do status (metadado estrutural). */
|
|
20
|
+
status: string;
|
|
21
|
+
/** Nome do responsável, ou placeholder de ausência (metadado estrutural). */
|
|
22
|
+
assignee: string;
|
|
23
|
+
}
|
|
24
|
+
/** Metadados de renderização da lista: consulta e avisos de degradação. */
|
|
25
|
+
export interface SearchListMeta {
|
|
26
|
+
/** Termo full-text usado (renderizado em fence por ser entrada arbitrária). */
|
|
27
|
+
query?: string | undefined;
|
|
28
|
+
/** Avisos a exibir no corpo (ex.: degradação da busca full-text). */
|
|
29
|
+
warnings?: string[] | undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Renderiza a lista compacta de resultados de busca em Markdown.
|
|
33
|
+
*
|
|
34
|
+
* @param items - Itens já resolvidos (status/responsável em nomes legíveis).
|
|
35
|
+
* @param meta - Consulta e avisos de degradação (ver {@link SearchListMeta}).
|
|
36
|
+
* @returns Documento Markdown terminado por uma quebra de linha.
|
|
37
|
+
* @example
|
|
38
|
+
* const md = buildSearchListMarkdown(
|
|
39
|
+
* [{ id: 1, subject: 'x', status: 'New', assignee: '(nenhum)' }],
|
|
40
|
+
* { query: 'x' },
|
|
41
|
+
* );
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildSearchListMarkdown(items: SearchListItem[], meta?: SearchListMeta): string;
|