@decibelsystems/tools 2.2.0-beta.0 → 3.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/CHANGELOG.md +160 -0
- package/dist/agentPresence.d.ts +80 -0
- package/dist/agentPresence.d.ts.map +1 -1
- package/dist/agentPresence.js +177 -0
- package/dist/agentPresence.js.map +1 -1
- package/dist/agentic/index.d.ts +18 -8
- package/dist/agentic/index.d.ts.map +1 -1
- package/dist/agentic/index.js +18 -144
- package/dist/agentic/index.js.map +1 -1
- package/dist/agentic/types.d.ts +106 -0
- package/dist/agentic/types.d.ts.map +1 -1
- package/dist/agentic/types.js +77 -1
- package/dist/agentic/types.js.map +1 -1
- package/dist/architectAdrs.d.ts +14 -2
- package/dist/architectAdrs.d.ts.map +1 -1
- package/dist/architectAdrs.js +9 -3
- package/dist/architectAdrs.js.map +1 -1
- package/dist/client/transports.d.ts.map +1 -1
- package/dist/client/transports.js +13 -3
- package/dist/client/transports.js.map +1 -1
- package/dist/daemon.d.ts +19 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +181 -38
- package/dist/daemon.js.map +1 -1
- package/dist/daemonConfig.d.ts +24 -0
- package/dist/daemonConfig.d.ts.map +1 -1
- package/dist/daemonConfig.js +46 -12
- package/dist/daemonConfig.js.map +1 -1
- package/dist/decibelPaths.d.ts.map +1 -1
- package/dist/decibelPaths.js +39 -6
- package/dist/decibelPaths.js.map +1 -1
- package/dist/domain/issue.d.ts +77 -0
- package/dist/domain/issue.d.ts.map +1 -0
- package/dist/domain/issue.js +134 -0
- package/dist/domain/issue.js.map +1 -0
- package/dist/domain/issueCodec.d.ts +39 -0
- package/dist/domain/issueCodec.d.ts.map +1 -0
- package/dist/domain/issueCodec.js +298 -0
- package/dist/domain/issueCodec.js.map +1 -0
- package/dist/domain/issueRepository.d.ts +91 -0
- package/dist/domain/issueRepository.d.ts.map +1 -0
- package/dist/domain/issueRepository.js +257 -0
- package/dist/domain/issueRepository.js.map +1 -0
- package/dist/facades/definitions.d.ts +0 -1
- package/dist/facades/definitions.d.ts.map +1 -1
- package/dist/facades/definitions.js +94 -113
- package/dist/facades/definitions.js.map +1 -1
- package/dist/facades/index.d.ts +2 -1
- package/dist/facades/index.d.ts.map +1 -1
- package/dist/facades/index.js +1 -1
- package/dist/facades/index.js.map +1 -1
- package/dist/facades/types.d.ts +17 -0
- package/dist/facades/types.d.ts.map +1 -1
- package/dist/httpArgs.d.ts +11 -0
- package/dist/httpArgs.d.ts.map +1 -0
- package/dist/httpArgs.js +39 -0
- package/dist/httpArgs.js.map +1 -0
- package/dist/httpServer.d.ts +1 -12
- package/dist/httpServer.d.ts.map +1 -1
- package/dist/httpServer.js +97 -96
- package/dist/httpServer.js.map +1 -1
- package/dist/kernel.d.ts +46 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +311 -26
- package/dist/kernel.js.map +1 -1
- package/dist/lib/agent-services/assumptions.d.ts.map +1 -1
- package/dist/lib/agent-services/assumptions.js +8 -2
- package/dist/lib/agent-services/assumptions.js.map +1 -1
- package/dist/lib/atomicWrite.d.ts +26 -0
- package/dist/lib/atomicWrite.d.ts.map +1 -0
- package/dist/lib/atomicWrite.js +73 -0
- package/dist/lib/atomicWrite.js.map +1 -0
- package/dist/lib/envelope.d.ts +31 -0
- package/dist/lib/envelope.d.ts.map +1 -0
- package/dist/lib/envelope.js +80 -0
- package/dist/lib/envelope.js.map +1 -0
- package/dist/lib/fileLock.d.ts +38 -0
- package/dist/lib/fileLock.d.ts.map +1 -0
- package/dist/lib/fileLock.js +124 -0
- package/dist/lib/fileLock.js.map +1 -0
- package/dist/lib/recordIdAllocator.d.ts +86 -0
- package/dist/lib/recordIdAllocator.d.ts.map +1 -0
- package/dist/lib/recordIdAllocator.js +161 -0
- package/dist/lib/recordIdAllocator.js.map +1 -0
- package/dist/projectRegistry.d.ts.map +1 -1
- package/dist/projectRegistry.js +11 -0
- package/dist/projectRegistry.js.map +1 -1
- package/dist/runtime/circuitBreaker.d.ts +95 -0
- package/dist/runtime/circuitBreaker.d.ts.map +1 -0
- package/dist/runtime/circuitBreaker.js +178 -0
- package/dist/runtime/circuitBreaker.js.map +1 -0
- package/dist/runtime/ensureRuntime.d.ts +103 -0
- package/dist/runtime/ensureRuntime.d.ts.map +1 -0
- package/dist/runtime/ensureRuntime.js +281 -0
- package/dist/runtime/ensureRuntime.js.map +1 -0
- package/dist/runtime/extensions.d.ts +57 -0
- package/dist/runtime/extensions.d.ts.map +1 -0
- package/dist/runtime/extensions.js +216 -0
- package/dist/runtime/extensions.js.map +1 -0
- package/dist/runtime/localHttp.d.ts +24 -0
- package/dist/runtime/localHttp.d.ts.map +1 -0
- package/dist/runtime/localHttp.js +84 -0
- package/dist/runtime/localHttp.js.map +1 -0
- package/dist/runtime/projectResolution.d.ts +29 -0
- package/dist/runtime/projectResolution.d.ts.map +1 -0
- package/dist/runtime/projectResolution.js +67 -0
- package/dist/runtime/projectResolution.js.map +1 -0
- package/dist/runtime/protocol.d.ts +62 -0
- package/dist/runtime/protocol.d.ts.map +1 -0
- package/dist/runtime/protocol.js +74 -0
- package/dist/runtime/protocol.js.map +1 -0
- package/dist/sentinelIssues.d.ts +26 -2
- package/dist/sentinelIssues.d.ts.map +1 -1
- package/dist/sentinelIssues.js +125 -64
- package/dist/sentinelIssues.js.map +1 -1
- package/dist/server.js +59 -12
- package/dist/server.js.map +1 -1
- package/dist/tools/agentic/index.d.ts.map +1 -1
- package/dist/tools/agentic/index.js +28 -2
- package/dist/tools/agentic/index.js.map +1 -1
- package/dist/tools/architect/index.d.ts.map +1 -1
- package/dist/tools/architect/index.js +7 -2
- package/dist/tools/architect/index.js.map +1 -1
- package/dist/tools/context.d.ts +10 -5
- package/dist/tools/context.d.ts.map +1 -1
- package/dist/tools/context.js +45 -18
- package/dist/tools/context.js.map +1 -1
- package/dist/tools/coordinator/coordinator.d.ts.map +1 -1
- package/dist/tools/coordinator/coordinator.js +48 -19
- package/dist/tools/coordinator/coordinator.js.map +1 -1
- package/dist/tools/crit.d.ts +2 -1
- package/dist/tools/crit.d.ts.map +1 -1
- package/dist/tools/crit.js +19 -4
- package/dist/tools/crit.js.map +1 -1
- package/dist/tools/designer/index.d.ts.map +1 -1
- package/dist/tools/designer/index.js +4 -3
- package/dist/tools/designer/index.js.map +1 -1
- package/dist/tools/designer/lateral-tools.d.ts.map +1 -1
- package/dist/tools/designer/lateral-tools.js +4 -2
- package/dist/tools/designer/lateral-tools.js.map +1 -1
- package/dist/tools/designer.d.ts +2 -1
- package/dist/tools/designer.d.ts.map +1 -1
- package/dist/tools/designer.js +24 -12
- package/dist/tools/designer.js.map +1 -1
- package/dist/tools/dojo.d.ts +3 -2
- package/dist/tools/dojo.d.ts.map +1 -1
- package/dist/tools/dojo.js +64 -22
- package/dist/tools/dojo.js.map +1 -1
- package/dist/tools/friction.d.ts.map +1 -1
- package/dist/tools/friction.js +16 -6
- package/dist/tools/friction.js.map +1 -1
- package/dist/tools/guardian.d.ts.map +1 -1
- package/dist/tools/guardian.js +6 -2
- package/dist/tools/guardian.js.map +1 -1
- package/dist/tools/hygiene/oracle-hygiene.d.ts.map +1 -1
- package/dist/tools/hygiene/oracle-hygiene.js +8 -2
- package/dist/tools/hygiene/oracle-hygiene.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +27 -18
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/learnings.d.ts +2 -1
- package/dist/tools/learnings.d.ts.map +1 -1
- package/dist/tools/learnings.js +31 -15
- package/dist/tools/learnings.js.map +1 -1
- package/dist/tools/oracle.d.ts.map +1 -1
- package/dist/tools/oracle.js +19 -6
- package/dist/tools/oracle.js.map +1 -1
- package/dist/tools/policy.d.ts +2 -1
- package/dist/tools/policy.d.ts.map +1 -1
- package/dist/tools/policy.js +28 -9
- package/dist/tools/policy.js.map +1 -1
- package/dist/tools/postoffice/index.d.ts +10 -0
- package/dist/tools/postoffice/index.d.ts.map +1 -0
- package/dist/tools/postoffice/index.js +218 -0
- package/dist/tools/postoffice/index.js.map +1 -0
- package/dist/tools/postoffice.d.ts +78 -0
- package/dist/tools/postoffice.d.ts.map +1 -0
- package/dist/tools/postoffice.js +199 -0
- package/dist/tools/postoffice.js.map +1 -0
- package/dist/tools/provenance.d.ts +7 -0
- package/dist/tools/provenance.d.ts.map +1 -1
- package/dist/tools/provenance.js +42 -16
- package/dist/tools/provenance.js.map +1 -1
- package/dist/tools/sentinel/index.d.ts +1 -0
- package/dist/tools/sentinel/index.d.ts.map +1 -1
- package/dist/tools/sentinel/index.js +88 -4
- package/dist/tools/sentinel/index.js.map +1 -1
- package/dist/tools/sentinel.d.ts +102 -1
- package/dist/tools/sentinel.d.ts.map +1 -1
- package/dist/tools/sentinel.js +544 -328
- package/dist/tools/sentinel.js.map +1 -1
- package/dist/tools/shared/index.d.ts +2 -1
- package/dist/tools/shared/index.d.ts.map +1 -1
- package/dist/tools/shared/index.js +2 -1
- package/dist/tools/shared/index.js.map +1 -1
- package/dist/tools/shared/response.d.ts +24 -0
- package/dist/tools/shared/response.d.ts.map +1 -1
- package/dist/tools/shared/response.js +61 -7
- package/dist/tools/shared/response.js.map +1 -1
- package/dist/tools/shared/storeRead.d.ts +153 -0
- package/dist/tools/shared/storeRead.d.ts.map +1 -0
- package/dist/tools/shared/storeRead.js +242 -0
- package/dist/tools/shared/storeRead.js.map +1 -0
- package/dist/tools/testSpec.d.ts +3 -2
- package/dist/tools/testSpec.d.ts.map +1 -1
- package/dist/tools/testSpec.js +51 -14
- package/dist/tools/testSpec.js.map +1 -1
- package/dist/tools/vector.d.ts +33 -1
- package/dist/tools/vector.d.ts.map +1 -1
- package/dist/tools/vector.js +31 -5
- package/dist/tools/vector.js.map +1 -1
- package/dist/tools/zoom/index.d.ts +15 -0
- package/dist/tools/zoom/index.d.ts.map +1 -0
- package/dist/tools/zoom/index.js +174 -0
- package/dist/tools/zoom/index.js.map +1 -0
- package/dist/tools/zoom.d.ts +234 -0
- package/dist/tools/zoom.d.ts.map +1 -0
- package/dist/tools/zoom.js +627 -0
- package/dist/tools/zoom.js.map +1 -0
- package/dist/transports/bridge.d.ts +1 -1
- package/dist/transports/bridge.d.ts.map +1 -1
- package/dist/transports/bridge.js +2 -0
- package/dist/transports/bridge.js.map +1 -1
- package/dist/transports/http.d.ts +1 -1
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +3 -1
- package/dist/transports/http.js.map +1 -1
- package/dist/transports/index.d.ts +1 -0
- package/dist/transports/index.d.ts.map +1 -1
- package/dist/transports/index.js +1 -0
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/mcp.d.ts +1 -1
- package/dist/transports/mcp.d.ts.map +1 -1
- package/dist/transports/mcp.js +7 -3
- package/dist/transports/mcp.js.map +1 -1
- package/dist/transports/stdio.d.ts +1 -1
- package/dist/transports/stdio.d.ts.map +1 -1
- package/dist/transports/stdio.js +2 -0
- package/dist/transports/stdio.js.map +1 -1
- package/dist/transports/thinStdio.d.ts +22 -0
- package/dist/transports/thinStdio.d.ts.map +1 -0
- package/dist/transports/thinStdio.js +218 -0
- package/dist/transports/thinStdio.js.map +1 -0
- package/dist/transports/types.d.ts +8 -1
- package/dist/transports/types.d.ts.map +1 -1
- package/dist/types/agent-services.d.ts +7 -0
- package/dist/types/agent-services.d.ts.map +1 -1
- package/package.json +9 -4
- package/templates/com.decibel.daemon.plist +6 -1
- package/dist/tools/deck.d.ts +0 -11
- package/dist/tools/deck.d.ts.map +0 -1
- package/dist/tools/deck.js +0 -1656
- package/dist/tools/deck.js.map +0 -1
- package/dist/tools/mother.d.ts +0 -11
- package/dist/tools/mother.d.ts.map +0 -1
- package/dist/tools/mother.js +0 -503
- package/dist/tools/mother.js.map +0 -1
- package/dist/tools/registry.d.ts +0 -3
- package/dist/tools/registry.d.ts.map +0 -1
- package/dist/tools/registry.js +0 -189
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/senken.d.ts +0 -11
- package/dist/tools/senken.d.ts.map +0 -1
- package/dist/tools/senken.js +0 -482
- package/dist/tools/senken.js.map +0 -1
- package/dist/tools/terminal.d.ts +0 -17
- package/dist/tools/terminal.d.ts.map +0 -1
- package/dist/tools/terminal.js +0 -968
- package/dist/tools/terminal.js.map +0 -1
- package/dist/tools/toolsIndex.d.ts +0 -5
- package/dist/tools/toolsIndex.d.ts.map +0 -1
- package/dist/tools/toolsIndex.js +0 -37
- package/dist/tools/toolsIndex.js.map +0 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Issue codec — EPIC-0038 Phase 3
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Translates between on-disk records and the canonical Issue model. This is the
|
|
5
|
+
// only place that knows there are two formats.
|
|
6
|
+
//
|
|
7
|
+
// Two rules shape the whole file:
|
|
8
|
+
//
|
|
9
|
+
// 1. DECODE IS LOSSLESS. Every frontmatter key survives a decode/encode round
|
|
10
|
+
// trip, whether or not the model has a field for it. Records in this store
|
|
11
|
+
// carry keys no interface declares — `repo`, `closed_reason`, `degraded`,
|
|
12
|
+
// `source`, `projectId` alongside `project` — and a codec that emitted only
|
|
13
|
+
// the fields it recognized would silently delete them on the first write.
|
|
14
|
+
// The precedent is real: update_issue once rewrote markdown records as bare
|
|
15
|
+
// YAML and dropped the delimiters, the heading and the body.
|
|
16
|
+
//
|
|
17
|
+
// 2. THE BODY MIRROR IS OUTPUT, NEVER INPUT. Markdown records repeat status and
|
|
18
|
+
// severity in the body as `**Status:** x` for human readers. Decode ignores
|
|
19
|
+
// those lines entirely and reads frontmatter; encode regenerates them from
|
|
20
|
+
// the model. That asymmetry is the point — it makes the drift that hit
|
|
21
|
+
// sixteen records structurally impossible rather than merely fixed. There is
|
|
22
|
+
// no code path that can write a mirror disagreeing with its frontmatter.
|
|
23
|
+
// ============================================================================
|
|
24
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
25
|
+
import { isUid, normalizeStatus, } from './issue.js';
|
|
26
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
27
|
+
/** Keys the model owns. Everything else is preserved verbatim in raw.data. */
|
|
28
|
+
const MODEL_KEYS = new Set([
|
|
29
|
+
'uid',
|
|
30
|
+
'id',
|
|
31
|
+
'title',
|
|
32
|
+
'status',
|
|
33
|
+
'severity',
|
|
34
|
+
'priority',
|
|
35
|
+
'tags',
|
|
36
|
+
'epic_id',
|
|
37
|
+
'epicId',
|
|
38
|
+
'project',
|
|
39
|
+
'description',
|
|
40
|
+
'details',
|
|
41
|
+
'resolution',
|
|
42
|
+
'created_at',
|
|
43
|
+
'updated_at',
|
|
44
|
+
'closed_at',
|
|
45
|
+
]);
|
|
46
|
+
function normalizeTags(v) {
|
|
47
|
+
if (!Array.isArray(v))
|
|
48
|
+
return undefined;
|
|
49
|
+
const tags = v.filter((t) => typeof t === 'string' && t.trim() !== '');
|
|
50
|
+
return tags.length > 0 ? tags : undefined;
|
|
51
|
+
}
|
|
52
|
+
function str(v) {
|
|
53
|
+
return typeof v === 'string' && v.trim() !== '' ? v : undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Split a bare-YAML record into its YAML head and any markdown tail at column 0.
|
|
57
|
+
*
|
|
58
|
+
* The column-0 anchor is load-bearing and must not gain `\s*`: headings indented
|
|
59
|
+
* inside a `description:` block scalar are body text, not a tail, and matching
|
|
60
|
+
* them would truncate the record at parse time. Phase 2 verified no record in
|
|
61
|
+
* the store relies on the difference, but nothing else pins it, so it is pinned
|
|
62
|
+
* by test here.
|
|
63
|
+
*/
|
|
64
|
+
export function splitBareYaml(content) {
|
|
65
|
+
const lines = content.split('\n');
|
|
66
|
+
const idx = lines.findIndex((l) => /^#{1,6}\s/.test(l));
|
|
67
|
+
if (idx < 0)
|
|
68
|
+
return { head: content, tail: '' };
|
|
69
|
+
return { head: lines.slice(0, idx).join('\n'), tail: lines.slice(idx).join('\n') };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Prose under `## Details`, which is where the markdown format keeps the body.
|
|
73
|
+
*
|
|
74
|
+
* This used to stop at the next `##` heading of any kind, which quietly made
|
|
75
|
+
* the format unable to hold a structured issue. Authors write `## Problem`,
|
|
76
|
+
* `## Root Cause`, `## Fix` — 44 records in this store did, and every one read
|
|
77
|
+
* back truncated at the first of them, in two cases from 107 characters to 30.
|
|
78
|
+
*
|
|
79
|
+
* Only ONE trailing section is not the author's: `## Resolution`, which
|
|
80
|
+
* encodeIssue regenerates from `issue.resolution` and which decode reads from
|
|
81
|
+
* frontmatter. Including it here would duplicate it into details and then
|
|
82
|
+
* re-append it on every write. Everything else belongs to the body.
|
|
83
|
+
*
|
|
84
|
+
* The trailing occurrence is the one stripped, not the first — an issue whose
|
|
85
|
+
* prose discusses a resolution mid-body keeps it.
|
|
86
|
+
*/
|
|
87
|
+
function extractDetails(body) {
|
|
88
|
+
const m = body.match(/^##\s+Details\s*\n([\s\S]*)$/m);
|
|
89
|
+
if (!m)
|
|
90
|
+
return undefined;
|
|
91
|
+
let rest = m[1];
|
|
92
|
+
const headings = [...rest.matchAll(/\n##\s+Resolution\s*(?:\n|$)/g)];
|
|
93
|
+
const last = headings[headings.length - 1];
|
|
94
|
+
if (last?.index !== undefined)
|
|
95
|
+
rest = rest.slice(0, last.index);
|
|
96
|
+
return rest.trim() || undefined;
|
|
97
|
+
}
|
|
98
|
+
export function decodeIssue(filename, content) {
|
|
99
|
+
const warnings = [];
|
|
100
|
+
const fm = FRONTMATTER_RE.exec(content);
|
|
101
|
+
let data;
|
|
102
|
+
let body;
|
|
103
|
+
let format;
|
|
104
|
+
if (fm) {
|
|
105
|
+
format = 'md';
|
|
106
|
+
body = fm[2];
|
|
107
|
+
try {
|
|
108
|
+
data = parseYaml(fm[1]) ?? {};
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
data = {};
|
|
112
|
+
warnings.push(`frontmatter did not parse: ${err instanceof Error ? err.message : String(err)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
format = 'yaml';
|
|
117
|
+
const split = splitBareYaml(content);
|
|
118
|
+
body = split.tail;
|
|
119
|
+
if (split.tail.trim()) {
|
|
120
|
+
// Phase 2 repaired every instance of this. A new one means a writer is
|
|
121
|
+
// appending markdown to bare YAML again.
|
|
122
|
+
warnings.push('bare-YAML record has a markdown tail — a writer is appending prose outside the YAML');
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
data = parseYaml(split.head) ?? {};
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
data = {};
|
|
129
|
+
warnings.push(`YAML did not parse: ${err instanceof Error ? err.message : String(err)}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (typeof data !== 'object' || data === null) {
|
|
133
|
+
data = {};
|
|
134
|
+
warnings.push('record did not parse to an object');
|
|
135
|
+
}
|
|
136
|
+
// ---- id: frontmatter ISS-NNNN, else the filename ------------------------
|
|
137
|
+
const fmId = str(data.id);
|
|
138
|
+
const id = (fmId && /^ISS-\d+$/i.test(fmId) ? fmId.toUpperCase() : undefined) ??
|
|
139
|
+
filename.match(/^(ISS-\d+)/i)?.[1]?.toUpperCase() ??
|
|
140
|
+
fmId ??
|
|
141
|
+
filename.replace(/\.(md|ya?ml)$/i, '');
|
|
142
|
+
// ---- title: frontmatter wins, then the heading, then the filename -------
|
|
143
|
+
//
|
|
144
|
+
// Frontmatter first, because the writer puts a title there ONLY when the
|
|
145
|
+
// body cannot hold it — no heading at all, or a title containing a newline,
|
|
146
|
+
// which a `# heading` truncates at the line break. Reading the heading first
|
|
147
|
+
// meant a multi-line title came back as its first line, from a record whose
|
|
148
|
+
// frontmatter held the whole thing: "first line\nsecond line" read back as
|
|
149
|
+
// "first line", with create_issue reporting success.
|
|
150
|
+
//
|
|
151
|
+
// Preferring frontmatter cannot regress a record that predates this, because
|
|
152
|
+
// the writer never emitted both: `title` went to frontmatter exactly when
|
|
153
|
+
// there was no heading to compete with it.
|
|
154
|
+
const heading = body.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
155
|
+
const fmTitle = str(data.title);
|
|
156
|
+
const title = fmTitle ?? heading ?? filename.replace(/\.(md|ya?ml)$/i, '');
|
|
157
|
+
if (!heading && !fmTitle) {
|
|
158
|
+
warnings.push('no title in heading or frontmatter — fell back to the filename');
|
|
159
|
+
}
|
|
160
|
+
// ---- status: the whole reason this codec exists -------------------------
|
|
161
|
+
const norm = normalizeStatus(data.status);
|
|
162
|
+
if (norm.unrecognized) {
|
|
163
|
+
warnings.push(`unrecognized status ${JSON.stringify(data.status)} — read as ${norm.status}`);
|
|
164
|
+
}
|
|
165
|
+
else if (norm.normalizedFrom) {
|
|
166
|
+
warnings.push(`status ${JSON.stringify(norm.normalizedFrom)} normalized to ${norm.status}`);
|
|
167
|
+
}
|
|
168
|
+
const uid = isUid(data.uid) ? data.uid : undefined;
|
|
169
|
+
if (data.uid !== undefined && !uid) {
|
|
170
|
+
warnings.push(`uid ${JSON.stringify(data.uid)} is not a valid UUIDv7 — ignored`);
|
|
171
|
+
}
|
|
172
|
+
const issue = {
|
|
173
|
+
uid,
|
|
174
|
+
id,
|
|
175
|
+
title,
|
|
176
|
+
status: norm.status,
|
|
177
|
+
severity: str(data.severity),
|
|
178
|
+
priority: str(data.priority),
|
|
179
|
+
// An empty tag list and no tag list mean the same thing, and encode omits
|
|
180
|
+
// empty arrays — so decoding [] to [] would make the codec non-idempotent.
|
|
181
|
+
// Twelve records in the store carry `tags: []`; the corpus test caught it.
|
|
182
|
+
tags: normalizeTags(data.tags),
|
|
183
|
+
epicId: str(data.epic_id) ?? str(data.epicId),
|
|
184
|
+
// Both spellings exist; `projectId` is the markdown writer's, `project` the
|
|
185
|
+
// YAML writer's. The model has one, and encode puts it back where it came from.
|
|
186
|
+
project: str(data.project) ?? str(data.projectId),
|
|
187
|
+
details: str(data.description) ?? extractDetails(body),
|
|
188
|
+
resolution: str(data.resolution),
|
|
189
|
+
created_at: str(data.created_at),
|
|
190
|
+
updated_at: str(data.updated_at),
|
|
191
|
+
closed_at: str(data.closed_at),
|
|
192
|
+
};
|
|
193
|
+
// Strip model-owned keys; whatever remains is carried through untouched.
|
|
194
|
+
const preserved = {};
|
|
195
|
+
for (const [k, v] of Object.entries(data)) {
|
|
196
|
+
if (!MODEL_KEYS.has(k) && k !== 'projectId')
|
|
197
|
+
preserved[k] = v;
|
|
198
|
+
}
|
|
199
|
+
return { issue, format, raw: { data: preserved, body }, warnings };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Rewrite the derived header lines in a markdown body from the model.
|
|
203
|
+
*
|
|
204
|
+
* Only lines that already exist are updated — this does not inject a mirror
|
|
205
|
+
* into a record that never had one, because the mirror is a convention of the
|
|
206
|
+
* markdown writer's layout, not a requirement of the format.
|
|
207
|
+
*/
|
|
208
|
+
function syncBodyMirror(body, issue) {
|
|
209
|
+
let out = body;
|
|
210
|
+
out = out.replace(/^\*\*Status:\*\* .*$/m, `**Status:** ${issue.status}`);
|
|
211
|
+
if (issue.severity) {
|
|
212
|
+
out = out.replace(/^\*\*Severity:\*\* .*$/m, `**Severity:** ${issue.severity}`);
|
|
213
|
+
}
|
|
214
|
+
if (issue.epicId) {
|
|
215
|
+
out = out.replace(/^\*\*Epic:\*\* .*$/m, `**Epic:** ${issue.epicId}`);
|
|
216
|
+
}
|
|
217
|
+
// The heading is the title's home in this format; keep them in step.
|
|
218
|
+
//
|
|
219
|
+
// Only ever the FIRST LINE. Splicing a multi-line title into the heading
|
|
220
|
+
// line pushed its remaining lines into the body as loose prose — and since
|
|
221
|
+
// the writer had already put them there once, they appeared twice. The
|
|
222
|
+
// authoritative copy of such a title is in frontmatter (see encodeIssue);
|
|
223
|
+
// this is the human-readable rendering of it.
|
|
224
|
+
out = out.replace(/^#\s+.+$/m, `# ${issue.title.split(/\r?\n/)[0]}`);
|
|
225
|
+
// Resolution is rendered into the body for the same reason status is: a
|
|
226
|
+
// markdown record is read by people, and `## Resolution` is where they look.
|
|
227
|
+
// It is regenerated from the model rather than stored there, so it cannot
|
|
228
|
+
// drift from frontmatter the way the status mirror did. Writing it ONLY to
|
|
229
|
+
// frontmatter would have been the tidier data model and the worse record —
|
|
230
|
+
// the prose would have silently vanished from every closed issue.
|
|
231
|
+
if (issue.resolution) {
|
|
232
|
+
const section = `## Resolution\n\n${issue.resolution}\n`;
|
|
233
|
+
out = /^##\s+Resolution\s*$/m.test(out)
|
|
234
|
+
? out.replace(/^##\s+Resolution\s*\n[\s\S]*?(?=\n##\s|$)/m, section)
|
|
235
|
+
: `${out.replace(/\s+$/, '')}\n\n${section}`;
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
export function encodeIssue(decoded) {
|
|
240
|
+
const { issue, format, raw } = decoded;
|
|
241
|
+
const data = {};
|
|
242
|
+
const put = (k, v) => {
|
|
243
|
+
if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0))
|
|
244
|
+
data[k] = v;
|
|
245
|
+
};
|
|
246
|
+
// Field order is stable so diffs stay readable across writes.
|
|
247
|
+
put('uid', issue.uid);
|
|
248
|
+
put('id', issue.id);
|
|
249
|
+
// Markdown records normally carry the title in the `# heading`, and writing
|
|
250
|
+
// it to frontmatter as well would create a second copy to drift — the same
|
|
251
|
+
// mistake as the status mirror. But a record with no heading has nowhere else
|
|
252
|
+
// to keep it, and omitting it there means the title is silently replaced by
|
|
253
|
+
// the filename on the next read. So: frontmatter holds the title exactly when
|
|
254
|
+
// the body cannot.
|
|
255
|
+
//
|
|
256
|
+
// A MULTI-LINE TITLE IS A TITLE THE BODY CANNOT HOLD. A markdown heading ends
|
|
257
|
+
// at the newline, so `# first\nsecond` is a heading reading "first" followed
|
|
258
|
+
// by a stray paragraph, and the record came back a line long with no error
|
|
259
|
+
// anywhere in the chain (ISS-0155). YAML quotes and escapes it exactly, so
|
|
260
|
+
// frontmatter is the only container that round-trips one.
|
|
261
|
+
const bodyHasHeading = /^#\s+.+$/m.test(raw.body);
|
|
262
|
+
const titleFitsAHeading = !/[\r\n]/.test(issue.title);
|
|
263
|
+
if (format === 'yaml' || !bodyHasHeading || !titleFitsAHeading)
|
|
264
|
+
put('title', issue.title);
|
|
265
|
+
// The markdown writer spells it projectId; keep each format's own spelling so
|
|
266
|
+
// a rewrite is not a gratuitous diff on every record.
|
|
267
|
+
put(format === 'md' ? 'projectId' : 'project', issue.project);
|
|
268
|
+
put('severity', issue.severity);
|
|
269
|
+
put('status', issue.status);
|
|
270
|
+
put('priority', issue.priority);
|
|
271
|
+
put('epic_id', issue.epicId);
|
|
272
|
+
put('tags', issue.tags);
|
|
273
|
+
put('created_at', issue.created_at);
|
|
274
|
+
put('updated_at', issue.updated_at);
|
|
275
|
+
put('closed_at', issue.closed_at);
|
|
276
|
+
// Bare YAML keeps prose in `description`; markdown keeps it in the body.
|
|
277
|
+
if (format === 'yaml')
|
|
278
|
+
put('description', issue.details);
|
|
279
|
+
put('resolution', issue.resolution);
|
|
280
|
+
// Preserved unknown keys land after the model's, so they never displace a
|
|
281
|
+
// known field's position but are never lost either.
|
|
282
|
+
for (const [k, v] of Object.entries(raw.data))
|
|
283
|
+
if (!(k in data))
|
|
284
|
+
data[k] = v;
|
|
285
|
+
const yaml = stringifyYaml(data, { lineWidth: 0 });
|
|
286
|
+
if (format === 'md') {
|
|
287
|
+
const body = syncBodyMirror(raw.body, issue);
|
|
288
|
+
return `---\n${yaml}---\n${body}`;
|
|
289
|
+
}
|
|
290
|
+
// A bare-YAML tail should not exist post-Phase-2, but if one is present it is
|
|
291
|
+
// pre-existing prose and gets preserved rather than dropped.
|
|
292
|
+
return raw.body.trim() ? `${yaml}\n${raw.body}` : yaml;
|
|
293
|
+
}
|
|
294
|
+
/** Apply changes to a decoded record, keeping derived state consistent. */
|
|
295
|
+
export function applyIssueChanges(decoded, changes) {
|
|
296
|
+
return { ...decoded, issue: { ...decoded.issue, ...changes } };
|
|
297
|
+
}
|
|
298
|
+
//# sourceMappingURL=issueCodec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"issueCodec.js","sourceRoot":"","sources":["../../src/domain/issueCodec.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Issue, IssuePriority, IssueSeverity, IssueStatus } from './issue.js';
|
|
2
|
+
import { IssueFormat } from './issueCodec.js';
|
|
3
|
+
/** An issue plus where it lives. The path is for diagnostics, not for callers to write through. */
|
|
4
|
+
export interface StoredIssue {
|
|
5
|
+
issue: Issue;
|
|
6
|
+
filename: string;
|
|
7
|
+
path: string;
|
|
8
|
+
format: IssueFormat;
|
|
9
|
+
/** Integrity observations from decoding. Empty for a healthy record. */
|
|
10
|
+
warnings: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface IssueListFilter {
|
|
13
|
+
status?: IssueStatus;
|
|
14
|
+
/** All non-terminal statuses. Distinct from status:'open', which is one value. */
|
|
15
|
+
liveOnly?: boolean;
|
|
16
|
+
epicId?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface CreateIssueSpec {
|
|
19
|
+
title: string;
|
|
20
|
+
details: string;
|
|
21
|
+
severity: IssueSeverity;
|
|
22
|
+
priority?: IssuePriority;
|
|
23
|
+
epicId?: string;
|
|
24
|
+
tags?: string[];
|
|
25
|
+
project?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Integrity state of the whole store, reported alongside a listing rather than
|
|
29
|
+
* discovered mid-incident.
|
|
30
|
+
*
|
|
31
|
+
* The distinction that matters: `malformed` records are invisible to callers,
|
|
32
|
+
* so a silent skip would make a broken store look like a shorter clean one.
|
|
33
|
+
* `degraded` records are readable but carry something the codec had to
|
|
34
|
+
* interpret. Both are repair queues, not cosmetics — a duplicate id makes an
|
|
35
|
+
* issue unwritable, because the repository refuses an ambiguous target.
|
|
36
|
+
*/
|
|
37
|
+
export interface StoreIntegrity {
|
|
38
|
+
malformed: string[];
|
|
39
|
+
degraded: Array<{
|
|
40
|
+
filename: string;
|
|
41
|
+
warnings: string[];
|
|
42
|
+
}>;
|
|
43
|
+
duplicateIds: Record<string, string[]>;
|
|
44
|
+
}
|
|
45
|
+
export interface IssueRepository {
|
|
46
|
+
integrity(): Promise<StoreIntegrity>;
|
|
47
|
+
list(filter?: IssueListFilter): Promise<StoredIssue[]>;
|
|
48
|
+
get(idOrFilename: string): Promise<StoredIssue | null>;
|
|
49
|
+
create(spec: CreateIssueSpec): Promise<StoredIssue>;
|
|
50
|
+
update(idOrFilename: string, changes: Partial<Issue>): Promise<StoredIssue>;
|
|
51
|
+
close(idOrFilename: string, resolution: string, status?: 'closed' | 'wontfix'): Promise<StoredIssue>;
|
|
52
|
+
}
|
|
53
|
+
export declare class AmbiguousIssueIdError extends Error {
|
|
54
|
+
readonly issueId: string;
|
|
55
|
+
readonly candidates: string[];
|
|
56
|
+
readonly code = "AMBIGUOUS_ISSUE_ID";
|
|
57
|
+
constructor(issueId: string, candidates: string[]);
|
|
58
|
+
}
|
|
59
|
+
export declare class IssueNotFoundError extends Error {
|
|
60
|
+
readonly issueId: string;
|
|
61
|
+
readonly code = "ISSUE_NOT_FOUND";
|
|
62
|
+
constructor(issueId: string);
|
|
63
|
+
}
|
|
64
|
+
export declare class FsIssueRepository implements IssueRepository {
|
|
65
|
+
private readonly issuesDir;
|
|
66
|
+
constructor(issuesDir: string);
|
|
67
|
+
private readAll;
|
|
68
|
+
/**
|
|
69
|
+
* Duplicate detection deliberately runs over EVERY record, never a filtered
|
|
70
|
+
* subset: a closed twin still makes an id ambiguous for writers, so filtering
|
|
71
|
+
* it out of a listing must not filter it out of the collision report.
|
|
72
|
+
*/
|
|
73
|
+
integrity(): Promise<StoreIntegrity>;
|
|
74
|
+
list(filter?: IssueListFilter): Promise<StoredIssue[]>;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve an id to exactly one record, in tiers of decreasing specificity.
|
|
77
|
+
*
|
|
78
|
+
* Only the first non-empty tier is considered, and ambiguity is reported
|
|
79
|
+
* within a tier rather than across tiers — so naming a record by its filename
|
|
80
|
+
* stays a reliable way to pick one member of a duplicate pair even while the
|
|
81
|
+
* shared ISS-NNNN remains ambiguous. uid is checked first because it is the
|
|
82
|
+
* only identifier guaranteed unique by construction.
|
|
83
|
+
*/
|
|
84
|
+
private resolve;
|
|
85
|
+
get(idOrFilename: string): Promise<StoredIssue | null>;
|
|
86
|
+
private requireOne;
|
|
87
|
+
create(spec: CreateIssueSpec): Promise<StoredIssue>;
|
|
88
|
+
update(idOrFilename: string, changes: Partial<Issue>): Promise<StoredIssue>;
|
|
89
|
+
close(idOrFilename: string, resolution: string, status?: 'closed' | 'wontfix'): Promise<StoredIssue>;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=issueRepository.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"issueRepository.d.ts","sourceRoot":"","sources":["../../src/domain/issueRepository.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Issue repository — EPIC-0038 Phase 3
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// The seam between "what an issue is" and "where issues live".
|
|
5
|
+
//
|
|
6
|
+
// Callers ask for issues by id and get back canonical Issue objects. They do
|
|
7
|
+
// not learn whether a record was markdown-with-frontmatter or bare YAML, which
|
|
8
|
+
// directory it came from, or how its id was resolved. That is the whole point:
|
|
9
|
+
// Phase 5 converges the on-disk format and should be a change to the codec and
|
|
10
|
+
// this file only, and a future SupabaseIssueRepository should be a sibling
|
|
11
|
+
// rather than a rewrite of every caller.
|
|
12
|
+
//
|
|
13
|
+
// Resolution is the subtle part. An id can name more than one record — four
|
|
14
|
+
// duplicate groups existed before Phase 2 repaired them — and the historical
|
|
15
|
+
// failure mode was `files.find(...)` picking whichever the directory listing
|
|
16
|
+
// happened to yield first, so writers had an undefined target and reported
|
|
17
|
+
// success either way. Ambiguity is therefore an error here, never a guess.
|
|
18
|
+
// ============================================================================
|
|
19
|
+
import { promises as fs } from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { writeFileAtomic } from '../lib/atomicWrite.js';
|
|
22
|
+
import { allocateAndWriteIssue } from '../lib/recordIdAllocator.js';
|
|
23
|
+
import { listDirOrThrow } from '../tools/shared/storeRead.js';
|
|
24
|
+
import { isLive, newUid, } from './issue.js';
|
|
25
|
+
import { decodeIssue, encodeIssue, } from './issueCodec.js';
|
|
26
|
+
export class AmbiguousIssueIdError extends Error {
|
|
27
|
+
issueId;
|
|
28
|
+
candidates;
|
|
29
|
+
code = 'AMBIGUOUS_ISSUE_ID';
|
|
30
|
+
constructor(issueId, candidates) {
|
|
31
|
+
super(`Issue id ${issueId} matches ${candidates.length} records: ${candidates.join(', ')}. ` +
|
|
32
|
+
`Name one by filename, or by its uid, to disambiguate.`);
|
|
33
|
+
this.issueId = issueId;
|
|
34
|
+
this.candidates = candidates;
|
|
35
|
+
this.name = 'AmbiguousIssueIdError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export class IssueNotFoundError extends Error {
|
|
39
|
+
issueId;
|
|
40
|
+
code = 'ISSUE_NOT_FOUND';
|
|
41
|
+
constructor(issueId) {
|
|
42
|
+
super(`Issue ${issueId} not found.`);
|
|
43
|
+
this.issueId = issueId;
|
|
44
|
+
this.name = 'IssueNotFoundError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const RECORD_RE = /\.(md|ya?ml)$/i;
|
|
48
|
+
function slugify(text) {
|
|
49
|
+
return text
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
52
|
+
.replace(/^-+|-+$/g, '')
|
|
53
|
+
.slice(0, 50);
|
|
54
|
+
}
|
|
55
|
+
export class FsIssueRepository {
|
|
56
|
+
issuesDir;
|
|
57
|
+
constructor(issuesDir) {
|
|
58
|
+
this.issuesDir = issuesDir;
|
|
59
|
+
}
|
|
60
|
+
async readAll() {
|
|
61
|
+
// ISS-0153. ENOENT is the only error that means "empty": a directory that
|
|
62
|
+
// was never created is what an untouched project looks like. EACCES,
|
|
63
|
+
// EPERM, EIO and the rest mean the issues may well be there and this
|
|
64
|
+
// process cannot see them — returning [] there reported "no issues" for a
|
|
65
|
+
// store it could not open, and list_issues is the read most likely to be
|
|
66
|
+
// believed.
|
|
67
|
+
const names = await listDirOrThrow(this.issuesDir, (f) => RECORD_RE.test(f));
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const filename of names) {
|
|
70
|
+
const full = path.join(this.issuesDir, filename);
|
|
71
|
+
let content;
|
|
72
|
+
try {
|
|
73
|
+
content = await fs.readFile(full, 'utf-8');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue; // Unreadable record: skip rather than fail the whole listing.
|
|
77
|
+
}
|
|
78
|
+
const decoded = decodeIssue(filename, content);
|
|
79
|
+
out.push({
|
|
80
|
+
issue: decoded.issue,
|
|
81
|
+
filename,
|
|
82
|
+
path: full,
|
|
83
|
+
format: decoded.format,
|
|
84
|
+
warnings: decoded.warnings,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Duplicate detection deliberately runs over EVERY record, never a filtered
|
|
91
|
+
* subset: a closed twin still makes an id ambiguous for writers, so filtering
|
|
92
|
+
* it out of a listing must not filter it out of the collision report.
|
|
93
|
+
*/
|
|
94
|
+
async integrity() {
|
|
95
|
+
const all = await this.readAll();
|
|
96
|
+
const malformed = [];
|
|
97
|
+
const degraded = [];
|
|
98
|
+
const claims = new Map();
|
|
99
|
+
for (const r of all) {
|
|
100
|
+
const unparseable = r.warnings.some((w) => w.includes('did not parse'));
|
|
101
|
+
if (unparseable)
|
|
102
|
+
malformed.push(r.filename);
|
|
103
|
+
else if (r.warnings.length > 0)
|
|
104
|
+
degraded.push({ filename: r.filename, warnings: r.warnings });
|
|
105
|
+
const key = r.issue.id.toUpperCase();
|
|
106
|
+
claims.set(key, [...(claims.get(key) ?? []), r.filename]);
|
|
107
|
+
}
|
|
108
|
+
const duplicateIds = {};
|
|
109
|
+
for (const [id, files] of claims)
|
|
110
|
+
if (files.length > 1)
|
|
111
|
+
duplicateIds[id] = files;
|
|
112
|
+
return { malformed, degraded, duplicateIds };
|
|
113
|
+
}
|
|
114
|
+
async list(filter = {}) {
|
|
115
|
+
// A record that did not parse is not an issue — returning it would hand
|
|
116
|
+
// callers a husk built from filename fallbacks. It is still counted by
|
|
117
|
+
// integrity(), so the file is reported rather than silently dropped.
|
|
118
|
+
let all = (await this.readAll()).filter((r) => !r.warnings.some((w) => w.includes('did not parse')));
|
|
119
|
+
if (filter.status)
|
|
120
|
+
all = all.filter((r) => r.issue.status === filter.status);
|
|
121
|
+
if (filter.liveOnly)
|
|
122
|
+
all = all.filter((r) => isLive(r.issue.status));
|
|
123
|
+
if (filter.epicId)
|
|
124
|
+
all = all.filter((r) => r.issue.epicId === filter.epicId);
|
|
125
|
+
return all;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Resolve an id to exactly one record, in tiers of decreasing specificity.
|
|
129
|
+
*
|
|
130
|
+
* Only the first non-empty tier is considered, and ambiguity is reported
|
|
131
|
+
* within a tier rather than across tiers — so naming a record by its filename
|
|
132
|
+
* stays a reliable way to pick one member of a duplicate pair even while the
|
|
133
|
+
* shared ISS-NNNN remains ambiguous. uid is checked first because it is the
|
|
134
|
+
* only identifier guaranteed unique by construction.
|
|
135
|
+
*/
|
|
136
|
+
async resolve(idOrFilename) {
|
|
137
|
+
const all = await this.readAll();
|
|
138
|
+
const needle = idOrFilename.trim();
|
|
139
|
+
const upper = needle.toUpperCase();
|
|
140
|
+
const byUid = all.filter((r) => r.issue.uid && r.issue.uid === needle);
|
|
141
|
+
if (byUid.length > 0)
|
|
142
|
+
return byUid;
|
|
143
|
+
const byFilename = all.filter((r) => r.filename.toUpperCase() === upper);
|
|
144
|
+
if (byFilename.length > 0)
|
|
145
|
+
return byFilename;
|
|
146
|
+
// Filename without the extension, which is how list output used to name records.
|
|
147
|
+
const byStem = all.filter((r) => r.filename.replace(RECORD_RE, '').toUpperCase() === upper.replace(RECORD_RE, ''));
|
|
148
|
+
if (byStem.length > 0)
|
|
149
|
+
return byStem;
|
|
150
|
+
// ISS-NNNN, requiring the id to end at a separator or the extension. A bare
|
|
151
|
+
// startsWith made "ISS-011" swallow ISS-0110, ISS-0112 and ISS-0119.
|
|
152
|
+
const byId = all.filter((r) => {
|
|
153
|
+
if (r.issue.id.toUpperCase() === upper)
|
|
154
|
+
return true;
|
|
155
|
+
const base = r.filename.toUpperCase();
|
|
156
|
+
if (!base.startsWith(upper))
|
|
157
|
+
return false;
|
|
158
|
+
const rest = base.slice(upper.length);
|
|
159
|
+
return rest === '' || rest.startsWith('-') || /^\.(MD|YA?ML)$/.test(rest);
|
|
160
|
+
});
|
|
161
|
+
if (byId.length > 0)
|
|
162
|
+
return byId;
|
|
163
|
+
// Last tier: a partial id like "ISS-011", which is a prefix of ISS-0110,
|
|
164
|
+
// ISS-0112 and ISS-0119. The boundary rule above deliberately refuses to
|
|
165
|
+
// resolve it, but refusing is not the same as saying nothing is there —
|
|
166
|
+
// "not found" would be a misleading answer when three records obviously
|
|
167
|
+
// start with it. So a loose prefix is reported as a collision when it spans
|
|
168
|
+
// several records, and is NEVER resolved to a single one: silently picking
|
|
169
|
+
// one is the swallow bug this whole tier system exists to prevent.
|
|
170
|
+
const loose = all.filter((r) => r.filename.toUpperCase().startsWith(upper));
|
|
171
|
+
return loose.length > 1 ? loose : [];
|
|
172
|
+
}
|
|
173
|
+
async get(idOrFilename) {
|
|
174
|
+
const matches = await this.resolve(idOrFilename);
|
|
175
|
+
if (matches.length === 0)
|
|
176
|
+
return null;
|
|
177
|
+
if (matches.length > 1) {
|
|
178
|
+
throw new AmbiguousIssueIdError(idOrFilename, matches.map((m) => m.filename));
|
|
179
|
+
}
|
|
180
|
+
return matches[0];
|
|
181
|
+
}
|
|
182
|
+
async requireOne(idOrFilename) {
|
|
183
|
+
const found = await this.get(idOrFilename);
|
|
184
|
+
if (!found)
|
|
185
|
+
throw new IssueNotFoundError(idOrFilename);
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
188
|
+
async create(spec) {
|
|
189
|
+
const now = new Date().toISOString();
|
|
190
|
+
const slug = slugify(spec.title);
|
|
191
|
+
// Allocation and write happen under one cross-process lock (Phase 1). Every
|
|
192
|
+
// new record gets a uid at birth, so the backfill only ever has to deal
|
|
193
|
+
// with records that predate this path.
|
|
194
|
+
const issue = {
|
|
195
|
+
uid: newUid(),
|
|
196
|
+
id: 'ISS-PENDING',
|
|
197
|
+
title: spec.title,
|
|
198
|
+
status: 'open',
|
|
199
|
+
severity: spec.severity,
|
|
200
|
+
priority: spec.priority,
|
|
201
|
+
tags: spec.tags,
|
|
202
|
+
epicId: spec.epicId,
|
|
203
|
+
project: spec.project,
|
|
204
|
+
details: spec.details,
|
|
205
|
+
created_at: now,
|
|
206
|
+
};
|
|
207
|
+
const build = (issueId) => {
|
|
208
|
+
const body = [
|
|
209
|
+
// First line only — a heading ends at the newline, so the rest would
|
|
210
|
+
// land in the body as stray prose. encodeIssue writes the whole title
|
|
211
|
+
// to frontmatter when it does not fit here.
|
|
212
|
+
`# ${spec.title.split(/\r?\n/)[0]}`,
|
|
213
|
+
'',
|
|
214
|
+
`**Severity:** ${spec.severity}`,
|
|
215
|
+
`**Status:** open`,
|
|
216
|
+
...(spec.epicId ? [`**Epic:** ${spec.epicId}`] : []),
|
|
217
|
+
'',
|
|
218
|
+
'## Details',
|
|
219
|
+
'',
|
|
220
|
+
spec.details,
|
|
221
|
+
'',
|
|
222
|
+
].join('\n');
|
|
223
|
+
const decoded = {
|
|
224
|
+
issue: { ...issue, id: issueId },
|
|
225
|
+
format: 'md',
|
|
226
|
+
raw: { data: {}, body },
|
|
227
|
+
warnings: [],
|
|
228
|
+
};
|
|
229
|
+
return encodeIssue(decoded);
|
|
230
|
+
};
|
|
231
|
+
const allocated = await allocateAndWriteIssue(this.issuesDir, 'md', slug, build);
|
|
232
|
+
return this.requireOne(allocated.filename);
|
|
233
|
+
}
|
|
234
|
+
async update(idOrFilename, changes) {
|
|
235
|
+
const current = await this.requireOne(idOrFilename);
|
|
236
|
+
const content = await fs.readFile(current.path, 'utf-8');
|
|
237
|
+
const decoded = decodeIssue(current.filename, content);
|
|
238
|
+
const updated = {
|
|
239
|
+
...decoded,
|
|
240
|
+
issue: {
|
|
241
|
+
...decoded.issue,
|
|
242
|
+
...changes,
|
|
243
|
+
// Identity is not editable through update. Renumbering is a migration.
|
|
244
|
+
uid: decoded.issue.uid ?? changes.uid,
|
|
245
|
+
id: decoded.issue.id,
|
|
246
|
+
updated_at: new Date().toISOString(),
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
await writeFileAtomic(current.path, encodeIssue(updated));
|
|
250
|
+
return this.requireOne(current.filename);
|
|
251
|
+
}
|
|
252
|
+
async close(idOrFilename, resolution, status = 'closed') {
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
return this.update(idOrFilename, { status, resolution, closed_at: now });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=issueRepository.js.map
|