@smartmemory/compose 0.1.34-beta → 0.1.35-beta
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/README.md +42 -0
- package/bin/compose.js +16 -0
- package/lib/build.js +60 -13
- package/lib/changelog-writer.js +111 -83
- package/lib/completion-writer.js +26 -9
- package/lib/feature-writer.js +62 -38
- package/lib/roadmap-gen.js +41 -14
- package/lib/tracker/cli.js +31 -0
- package/lib/tracker/factory.js +93 -0
- package/lib/tracker/github-api.js +115 -0
- package/lib/tracker/github-provider.js +641 -0
- package/lib/tracker/local-provider.js +202 -0
- package/lib/tracker/provider.js +40 -0
- package/lib/tracker/sync-engine.js +131 -0
- package/package.json +3 -2
package/lib/feature-writer.js
CHANGED
|
@@ -19,13 +19,18 @@
|
|
|
19
19
|
import { existsSync, realpathSync, statSync } from 'fs';
|
|
20
20
|
import { resolve, normalize, sep, basename, dirname } from 'path';
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
const _listFeatures = listFeatures;
|
|
24
|
-
import { writeRoadmap } from './roadmap-gen.js';
|
|
25
|
-
import { appendEvent, readEvents } from './feature-events.js';
|
|
22
|
+
import { readEvents } from './feature-events.js';
|
|
26
23
|
import { checkOrInsert } from './idempotency.js';
|
|
27
24
|
import { loadFeaturesDir } from './project-paths.js';
|
|
28
25
|
|
|
26
|
+
// providerFor is imported lazily (inside each function) to break the
|
|
27
|
+
// module-load-time cycle: factory.js → local-provider.js → feature-writer.js.
|
|
28
|
+
// Dynamic import resolves at call time, after all modules have loaded.
|
|
29
|
+
async function getProvider(cwd) {
|
|
30
|
+
const { providerFor } = await import('./tracker/factory.js');
|
|
31
|
+
return providerFor(cwd);
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
// ---------------------------------------------------------------------------
|
|
30
35
|
// Status / transition policy
|
|
31
36
|
// ---------------------------------------------------------------------------
|
|
@@ -99,9 +104,10 @@ export async function addRoadmapEntry(cwd, args) {
|
|
|
99
104
|
throw new Error(`feature-writer: invalid status "${status}"`);
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
return maybeIdempotent({ ...args, cwd }, async () => {
|
|
108
|
+
const provider = await getProvider(cwd);
|
|
109
|
+
|
|
110
|
+
const existing = await provider.getFeature(args.code);
|
|
105
111
|
if (existing) {
|
|
106
112
|
throw new Error(`feature-writer: feature "${args.code}" already exists`);
|
|
107
113
|
}
|
|
@@ -118,14 +124,18 @@ export async function addRoadmapEntry(cwd, args) {
|
|
|
118
124
|
if (args.complexity) feature.complexity = args.complexity;
|
|
119
125
|
feature.position = args.position !== undefined
|
|
120
126
|
? args.position
|
|
121
|
-
: nextPositionInPhase(
|
|
127
|
+
: await nextPositionInPhase(provider, args.phase);
|
|
122
128
|
if (args.parent) feature.parent = args.parent;
|
|
123
129
|
if (args.tags && args.tags.length) feature.tags = args.tags;
|
|
124
130
|
|
|
125
|
-
|
|
131
|
+
// Use createFeature (not putFeature) for the initial write of a brand-new
|
|
132
|
+
// feature: the not-found check above has already confirmed it doesn't exist,
|
|
133
|
+
// and createFeature carries the correct semantics for remote providers
|
|
134
|
+
// (e.g. GitHubProvider creates a new issue rather than patching an existing one).
|
|
135
|
+
await provider.createFeature(args.code, feature);
|
|
126
136
|
let roadmapPath;
|
|
127
137
|
try {
|
|
128
|
-
roadmapPath =
|
|
138
|
+
roadmapPath = await provider.renderRoadmap();
|
|
129
139
|
} catch (err) {
|
|
130
140
|
throw partialWriteError(
|
|
131
141
|
`add_roadmap_entry: feature.json for "${args.code}" was written but ROADMAP.md regeneration failed. ` +
|
|
@@ -134,7 +144,7 @@ export async function addRoadmapEntry(cwd, args) {
|
|
|
134
144
|
);
|
|
135
145
|
}
|
|
136
146
|
|
|
137
|
-
safeAppendEvent(cwd, {
|
|
147
|
+
await safeAppendEvent(cwd, {
|
|
138
148
|
tool: 'add_roadmap_entry',
|
|
139
149
|
code: args.code,
|
|
140
150
|
to: status,
|
|
@@ -153,8 +163,9 @@ export async function addRoadmapEntry(cwd, args) {
|
|
|
153
163
|
|
|
154
164
|
// Default position for a new feature: max existing position in the same
|
|
155
165
|
// phase, plus 1. Falls back to 1 when the phase is empty.
|
|
156
|
-
function nextPositionInPhase(
|
|
157
|
-
const
|
|
166
|
+
async function nextPositionInPhase(provider, phase) {
|
|
167
|
+
const all = await provider.listFeatures();
|
|
168
|
+
const peers = all.filter(f => f.phase === phase);
|
|
158
169
|
if (peers.length === 0) return 1;
|
|
159
170
|
const maxPos = peers.reduce((m, f) => {
|
|
160
171
|
const p = typeof f.position === 'number' ? f.position : 0;
|
|
@@ -178,9 +189,14 @@ function partialWriteError(message, cause) {
|
|
|
178
189
|
// Audit-log writes are best-effort: a failed append must NOT roll back a
|
|
179
190
|
// committed mutation (per design Decision 2 and docs/mcp.md). Log a warning
|
|
180
191
|
// and continue.
|
|
181
|
-
|
|
192
|
+
//
|
|
193
|
+
// Routes through provider.appendEvent so GitHubProvider can post
|
|
194
|
+
// <!--compose-event--> comments + mirror Projects v2. LocalFileProvider
|
|
195
|
+
// delegates to feature-events.js#appendEvent producing byte-identical output.
|
|
196
|
+
async function safeAppendEvent(cwd, event) {
|
|
182
197
|
try {
|
|
183
|
-
|
|
198
|
+
const provider = await getProvider(cwd);
|
|
199
|
+
await provider.appendEvent(event.code, event);
|
|
184
200
|
} catch (err) {
|
|
185
201
|
// eslint-disable-next-line no-console
|
|
186
202
|
console.warn(`[feature-writer] audit append failed for ${event.tool} ${event.code ?? ''}: ${err.message}`);
|
|
@@ -207,9 +223,10 @@ export async function setFeatureStatus(cwd, args) {
|
|
|
207
223
|
throw new Error(`feature-writer: invalid status "${args.status}" — must be one of ${[...STATUSES].join(', ')}`);
|
|
208
224
|
}
|
|
209
225
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
226
|
+
return maybeIdempotent({ ...args, cwd }, async () => {
|
|
227
|
+
const provider = await getProvider(cwd);
|
|
228
|
+
|
|
229
|
+
const feature = await provider.getFeature(args.code);
|
|
213
230
|
if (!feature) {
|
|
214
231
|
throw new Error(`feature-writer: feature "${args.code}" not found`);
|
|
215
232
|
}
|
|
@@ -230,11 +247,14 @@ export async function setFeatureStatus(cwd, args) {
|
|
|
230
247
|
);
|
|
231
248
|
}
|
|
232
249
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
250
|
+
// Build the updated feature object. We use persistFeatureRaw (not putFeature)
|
|
251
|
+
// because putFeature rejects status deltas by contract. Transition policy
|
|
252
|
+
// enforcement has already happened above — this is the raw persistence step.
|
|
253
|
+
const updated = { ...feature, status: to };
|
|
254
|
+
if (args.commit_sha) updated.commit_sha = args.commit_sha;
|
|
255
|
+
await provider.persistFeatureRaw(args.code, updated);
|
|
236
256
|
try {
|
|
237
|
-
|
|
257
|
+
await provider.renderRoadmap();
|
|
238
258
|
} catch (err) {
|
|
239
259
|
throw partialWriteError(
|
|
240
260
|
`set_feature_status: feature.json for "${args.code}" was updated (${from} → ${to}) but ROADMAP.md regeneration failed. ` +
|
|
@@ -253,7 +273,7 @@ export async function setFeatureStatus(cwd, args) {
|
|
|
253
273
|
if (args.reason) event.reason = args.reason;
|
|
254
274
|
if (args.commit_sha) event.commit_sha = args.commit_sha;
|
|
255
275
|
if (args.force && !allowed.includes(to)) event.forced = true;
|
|
256
|
-
safeAppendEvent(cwd, event);
|
|
276
|
+
await safeAppendEvent(cwd, event);
|
|
257
277
|
|
|
258
278
|
return { code: args.code, from, to, ts: new Date().toISOString() };
|
|
259
279
|
});
|
|
@@ -382,8 +402,10 @@ export async function linkArtifact(cwd, args) {
|
|
|
382
402
|
const featuresDir = loadFeaturesDir(cwd);
|
|
383
403
|
rejectCanonicalArtifact(featuresDir, args.feature_code, normalizedPath);
|
|
384
404
|
|
|
385
|
-
return maybeIdempotent({ ...args, cwd }, () => {
|
|
386
|
-
const
|
|
405
|
+
return maybeIdempotent({ ...args, cwd }, async () => {
|
|
406
|
+
const provider = await getProvider(cwd);
|
|
407
|
+
|
|
408
|
+
const feature = await provider.getFeature(args.feature_code);
|
|
387
409
|
if (!feature) {
|
|
388
410
|
throw new Error(`feature-writer: feature "${args.feature_code}" not found`);
|
|
389
411
|
}
|
|
@@ -408,9 +430,9 @@ export async function linkArtifact(cwd, args) {
|
|
|
408
430
|
if (matchIdx !== -1) artifacts[matchIdx] = entry;
|
|
409
431
|
else artifacts.push(entry);
|
|
410
432
|
|
|
411
|
-
|
|
433
|
+
await provider.putFeature(args.feature_code, { ...feature, artifacts });
|
|
412
434
|
|
|
413
|
-
safeAppendEvent(cwd, {
|
|
435
|
+
await safeAppendEvent(cwd, {
|
|
414
436
|
tool: 'link_artifact',
|
|
415
437
|
code: args.feature_code,
|
|
416
438
|
artifact_type: args.artifact_type,
|
|
@@ -452,9 +474,10 @@ export async function linkFeatures(cwd, args) {
|
|
|
452
474
|
);
|
|
453
475
|
}
|
|
454
476
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
477
|
+
return maybeIdempotent({ ...args, cwd }, async () => {
|
|
478
|
+
const provider = await getProvider(cwd);
|
|
479
|
+
|
|
480
|
+
const feature = await provider.getFeature(args.from_code);
|
|
458
481
|
if (!feature) {
|
|
459
482
|
throw new Error(`feature-writer: feature "${args.from_code}" not found`);
|
|
460
483
|
}
|
|
@@ -474,9 +497,9 @@ export async function linkFeatures(cwd, args) {
|
|
|
474
497
|
if (matchIdx !== -1) links[matchIdx] = entry;
|
|
475
498
|
else links.push(entry);
|
|
476
499
|
|
|
477
|
-
|
|
500
|
+
await provider.putFeature(args.from_code, { ...feature, links });
|
|
478
501
|
|
|
479
|
-
safeAppendEvent(cwd, {
|
|
502
|
+
await safeAppendEvent(cwd, {
|
|
480
503
|
tool: 'link_features',
|
|
481
504
|
code: args.from_code,
|
|
482
505
|
to_code: args.to_code,
|
|
@@ -508,8 +531,8 @@ export async function linkFeatures(cwd, args) {
|
|
|
508
531
|
*/
|
|
509
532
|
export async function getFeatureArtifacts(cwd, args) {
|
|
510
533
|
validateCode(args.feature_code);
|
|
511
|
-
const
|
|
512
|
-
const feature =
|
|
534
|
+
const provider = await getProvider(cwd);
|
|
535
|
+
const feature = await provider.getFeature(args.feature_code);
|
|
513
536
|
if (!feature) {
|
|
514
537
|
throw new Error(`feature-writer: feature "${args.feature_code}" not found`);
|
|
515
538
|
}
|
|
@@ -525,6 +548,7 @@ export async function getFeatureArtifacts(cwd, args) {
|
|
|
525
548
|
let canonical = null;
|
|
526
549
|
try {
|
|
527
550
|
const { ArtifactManager } = await import('../server/artifact-manager.js');
|
|
551
|
+
const featuresDir = loadFeaturesDir(cwd);
|
|
528
552
|
const featureRoot = resolve(realCwd, featuresDir);
|
|
529
553
|
if (existsSync(featureRoot)) {
|
|
530
554
|
const manager = new ArtifactManager(featureRoot);
|
|
@@ -550,7 +574,7 @@ export async function getFeatureArtifacts(cwd, args) {
|
|
|
550
574
|
* @param {'outgoing'|'incoming'|'both'} [args.direction='both']
|
|
551
575
|
* @param {string} [args.kind]
|
|
552
576
|
*/
|
|
553
|
-
export function getFeatureLinks(cwd, args) {
|
|
577
|
+
export async function getFeatureLinks(cwd, args) {
|
|
554
578
|
validateCode(args.feature_code);
|
|
555
579
|
const direction = args.direction ?? 'both';
|
|
556
580
|
if (!['outgoing', 'incoming', 'both'].includes(direction)) {
|
|
@@ -560,11 +584,11 @@ export function getFeatureLinks(cwd, args) {
|
|
|
560
584
|
}
|
|
561
585
|
const kind = args.kind;
|
|
562
586
|
|
|
563
|
-
const
|
|
587
|
+
const provider = await getProvider(cwd);
|
|
564
588
|
const out = { feature_code: args.feature_code };
|
|
565
589
|
|
|
566
590
|
if (direction === 'outgoing' || direction === 'both') {
|
|
567
|
-
const feature =
|
|
591
|
+
const feature = await provider.getFeature(args.feature_code);
|
|
568
592
|
if (!feature) {
|
|
569
593
|
throw new Error(`feature-writer: feature "${args.feature_code}" not found`);
|
|
570
594
|
}
|
|
@@ -574,7 +598,7 @@ export function getFeatureLinks(cwd, args) {
|
|
|
574
598
|
}
|
|
575
599
|
|
|
576
600
|
if (direction === 'incoming' || direction === 'both') {
|
|
577
|
-
const all =
|
|
601
|
+
const all = await provider.listFeatures();
|
|
578
602
|
const incoming = [];
|
|
579
603
|
for (const f of all) {
|
|
580
604
|
if (f.code === args.feature_code) continue;
|
package/lib/roadmap-gen.js
CHANGED
|
@@ -46,22 +46,23 @@ function phaseStatus(features) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
49
|
+
* Pure transform: merge a features array into a base ROADMAP.md text string
|
|
50
|
+
* and return the resulting text. No filesystem access.
|
|
50
51
|
*
|
|
51
|
-
* @param {string}
|
|
52
|
+
* @param {string} baseText - The existing ROADMAP.md content (empty string for a fresh file)
|
|
53
|
+
* @param {Array} features - Feature objects (as returned by listFeatures)
|
|
52
54
|
* @param {object} [opts]
|
|
53
|
-
* @param {string} [opts.
|
|
54
|
-
* @param {string} [opts.
|
|
55
|
-
* @param {string} [opts.
|
|
56
|
-
* @
|
|
55
|
+
* @param {string} [opts.projectName] - Project name for default preamble
|
|
56
|
+
* @param {string} [opts.projectDescription] - Project description for default preamble
|
|
57
|
+
* @param {string} [opts.cwd] - Used only for drift emission (optional; defaults to '')
|
|
58
|
+
* @param {string} [opts.featuresDir] - Passed through to buildKeyDocs (optional)
|
|
59
|
+
* @returns {string} - Merged ROADMAP.md content
|
|
57
60
|
*/
|
|
58
|
-
export function
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
+
export function generateRoadmapFromBase(baseText, features, opts = {}) {
|
|
62
|
+
const cwd = opts.cwd ?? '';
|
|
63
|
+
const featuresDir = opts.featuresDir ?? 'docs/features';
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
const roadmapPath = join(cwd, 'ROADMAP.md');
|
|
64
|
-
const existingText = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
|
|
65
|
+
const existingText = baseText ?? '';
|
|
65
66
|
const preamble = readPreamble(cwd, opts, existingText);
|
|
66
67
|
const overrides = readPhaseOverrides(existingText);
|
|
67
68
|
const anonRows = readAnonymousRows(existingText);
|
|
@@ -141,7 +142,7 @@ export function generateRoadmap(cwd, opts = {}) {
|
|
|
141
142
|
if (override) {
|
|
142
143
|
const overrideToken = parseStatusToken(override);
|
|
143
144
|
if (overrideToken && overrideToken !== rollupStatus) {
|
|
144
|
-
emitDrift(cwd, { phaseId: phase, override, computed: rollupStatus });
|
|
145
|
+
if (cwd) emitDrift(cwd, { phaseId: phase, override, computed: rollupStatus });
|
|
145
146
|
}
|
|
146
147
|
// Override always wins. We can't reliably distinguish curated overrides
|
|
147
148
|
// from previously-auto-generated rollups without explicit marking, so
|
|
@@ -189,6 +190,27 @@ export function generateRoadmap(cwd, opts = {}) {
|
|
|
189
190
|
return sections.join('\n\n---\n\n') + '\n';
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Generate ROADMAP.md content from feature.json files.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} cwd - Project root
|
|
197
|
+
* @param {object} [opts]
|
|
198
|
+
* @param {string} [opts.featuresDir] - Relative path to features dir
|
|
199
|
+
* @param {string} [opts.projectName] - Project name for header
|
|
200
|
+
* @param {string} [opts.projectDescription] - Project description for header
|
|
201
|
+
* @returns {string} - Generated ROADMAP.md content
|
|
202
|
+
*/
|
|
203
|
+
export function generateRoadmap(cwd, opts = {}) {
|
|
204
|
+
const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
|
|
205
|
+
const features = listFeatures(cwd, featuresDir);
|
|
206
|
+
|
|
207
|
+
// Read existing ROADMAP.md once: preamble + curated content for splice-back.
|
|
208
|
+
const roadmapPath = join(cwd, 'ROADMAP.md');
|
|
209
|
+
const existingText = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
|
|
210
|
+
|
|
211
|
+
return generateRoadmapFromBase(existingText, features, { ...opts, cwd, featuresDir });
|
|
212
|
+
}
|
|
213
|
+
|
|
192
214
|
/**
|
|
193
215
|
* Read the preamble (everything before the first ## Phase/Feature section)
|
|
194
216
|
* from an existing ROADMAP.md, or generate a default one.
|
|
@@ -206,7 +228,12 @@ function readPreamble(cwd, opts, existingText) {
|
|
|
206
228
|
if (firstHeadingIdx === -1 || idx < firstHeadingIdx) firstHeadingIdx = idx;
|
|
207
229
|
}
|
|
208
230
|
}
|
|
209
|
-
if (firstHeadingIdx
|
|
231
|
+
if (firstHeadingIdx === -1) {
|
|
232
|
+
// No phase headings found — the entire file is a preamble (e.g. remote file
|
|
233
|
+
// contains only a header/intro with no generated sections yet). Preserve it.
|
|
234
|
+
const stripped = existingText.trimEnd().replace(/\n---\s*$/, '').trimEnd();
|
|
235
|
+
if (stripped.length > 0) return stripped;
|
|
236
|
+
} else if (firstHeadingIdx > 0) {
|
|
210
237
|
// Walk back over a possible `---\n\n` separator immediately before the heading
|
|
211
238
|
// so it doesn't get duplicated against the join("\n\n---\n\n") below.
|
|
212
239
|
let cutIdx = firstHeadingIdx;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { providerFor } from './factory.js';
|
|
2
|
+
|
|
3
|
+
export async function runTrackerCli(cwd, argv) {
|
|
4
|
+
const sub = argv[0];
|
|
5
|
+
|
|
6
|
+
// Guard unknown/missing subcommand BEFORE touching providerFor (no I/O on bad input).
|
|
7
|
+
if (sub !== 'status' && sub !== 'sync') {
|
|
8
|
+
return { output: 'usage: compose tracker <status|sync>', exitCode: 1 };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const provider = await providerFor(cwd);
|
|
12
|
+
|
|
13
|
+
if (sub === 'status') {
|
|
14
|
+
const h = await provider.health();
|
|
15
|
+
const output = [
|
|
16
|
+
`tracker provider: ${h.provider}`,
|
|
17
|
+
`canonical: ${h.canonical}`,
|
|
18
|
+
`pendingOps: ${h.pendingOps}`,
|
|
19
|
+
`conflicts: ${h.conflicts}`,
|
|
20
|
+
`mixedSources: ${(h.mixedSources || []).join(', ') || '(none)'}`,
|
|
21
|
+
].join('\n');
|
|
22
|
+
return { output, exitCode: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// sub === 'sync'
|
|
26
|
+
const r = await provider.sync();
|
|
27
|
+
return {
|
|
28
|
+
output: `sync: drained ${r.drained}, quarantined ${r.quarantined ?? 0}, pending ${r.pending ?? 0}`,
|
|
29
|
+
exitCode: 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { LocalFileProvider } from './local-provider.js';
|
|
4
|
+
import { TrackerConfigError } from './provider.js';
|
|
5
|
+
|
|
6
|
+
function loadTrackerConfig(cwd) {
|
|
7
|
+
const p = join(cwd, '.compose/compose.json');
|
|
8
|
+
// Absent file → local default (valid, not misconfig).
|
|
9
|
+
if (!existsSync(p)) return { provider: 'local' };
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
13
|
+
} catch (e) {
|
|
14
|
+
// File EXISTS but JSON is malformed → fail fast (never silently fall back;
|
|
15
|
+
// silent fallback would mask misconfig — design.md Error Handling).
|
|
16
|
+
throw new TrackerConfigError(
|
|
17
|
+
`compose: tracker config at ${p} contains invalid JSON — ${e.message}`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
// Absent tracker key → local default (valid).
|
|
21
|
+
const tracker = parsed.tracker;
|
|
22
|
+
if (tracker === undefined || tracker === null) return { provider: 'local' };
|
|
23
|
+
// tracker key present but structurally invalid → fail fast.
|
|
24
|
+
if (typeof tracker !== 'object' || Array.isArray(tracker)) {
|
|
25
|
+
throw new TrackerConfigError(
|
|
26
|
+
`compose: tracker config at ${p} has a "tracker" key but it is not an object (got ${Array.isArray(tracker) ? 'array' : typeof tracker})`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return tracker;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ENTITY_METHODS = {
|
|
33
|
+
JOURNAL: ['readJournal', 'writeJournalEntry'],
|
|
34
|
+
VISION: ['getVisionState', 'putVisionState'],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function withFallback(active, local) {
|
|
38
|
+
const caps = active.capabilities();
|
|
39
|
+
return new Proxy(active, {
|
|
40
|
+
get(target, prop, receiver) {
|
|
41
|
+
if (typeof prop !== 'string') return Reflect.get(target, prop, receiver);
|
|
42
|
+
for (const [cap, methods] of Object.entries(ENTITY_METHODS)) {
|
|
43
|
+
if (methods.includes(prop) && !caps.has(cap)) {
|
|
44
|
+
const fn = local[prop];
|
|
45
|
+
return typeof fn === 'function' ? fn.bind(local) : fn;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const v = target[prop];
|
|
49
|
+
return typeof v === 'function' ? v.bind(target) : v;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Test transport injection (TEST-ONLY — no production effect)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
//
|
|
58
|
+
// Tests that drive PRODUCTION entry points (writers) through a GitHub-configured
|
|
59
|
+
// project need to inject a fixture transport WITHOUT modifying the on-disk config
|
|
60
|
+
// (which can't hold a function). Call `setTestTransport(transport)` before
|
|
61
|
+
// constructing the provider; call `clearTestTransport()` in afterEach.
|
|
62
|
+
//
|
|
63
|
+
// Production behavior: `_testTransport` is undefined by default → no effect.
|
|
64
|
+
//
|
|
65
|
+
let _testTransport = undefined;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object|null} transport - fixture transport for tests; null to clear.
|
|
69
|
+
*/
|
|
70
|
+
export function setTestTransport(transport) {
|
|
71
|
+
_testTransport = transport ?? undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function clearTestTransport() {
|
|
75
|
+
_testTransport = undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function providerFor(cwd) {
|
|
79
|
+
const cfg = loadTrackerConfig(cwd);
|
|
80
|
+
const local = await new LocalFileProvider().init(cwd, {});
|
|
81
|
+
if (!cfg.provider || cfg.provider === 'local') return local;
|
|
82
|
+
if (cfg.provider === 'github') {
|
|
83
|
+
const { GitHubProvider } = await import('./github-provider.js');
|
|
84
|
+
// Merge in the test-only transport if one has been set via setTestTransport().
|
|
85
|
+
// In production _testTransport is undefined and cfg.github is passed as-is.
|
|
86
|
+
const ghCfg = _testTransport !== undefined
|
|
87
|
+
? { ...(cfg.github ?? {}), _transport: _testTransport }
|
|
88
|
+
: (cfg.github ?? {});
|
|
89
|
+
const gh = await new GitHubProvider().init(cwd, ghCfg);
|
|
90
|
+
return withFallback(gh, local);
|
|
91
|
+
}
|
|
92
|
+
throw new TrackerConfigError(`unknown tracker provider "${cfg.provider}"`);
|
|
93
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { TrackerConfigError } from './provider.js';
|
|
3
|
+
|
|
4
|
+
function resolveToken(auth = {}, noGhFallback = false) {
|
|
5
|
+
if (auth.token) return auth.token;
|
|
6
|
+
if (auth.tokenEnv && process.env[auth.tokenEnv]) return process.env[auth.tokenEnv];
|
|
7
|
+
if (noGhFallback) return null;
|
|
8
|
+
try { return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim() || null; }
|
|
9
|
+
catch { return null; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class GitHubApi {
|
|
13
|
+
constructor(cfg, transport = null) {
|
|
14
|
+
this.repo = cfg.repo;
|
|
15
|
+
if (!this.repo || !/^[^/]+\/[^/]+$/.test(this.repo)) {
|
|
16
|
+
throw new TrackerConfigError(`tracker.github.repo must be "owner/name" (got "${this.repo}")`);
|
|
17
|
+
}
|
|
18
|
+
this.token = resolveToken(cfg.auth, cfg.auth?._noGhFallback || cfg._noGhFallback);
|
|
19
|
+
if (!this.token) {
|
|
20
|
+
throw new TrackerConfigError('no GitHub token: set tracker.github.auth.tokenEnv or run `gh auth login`',
|
|
21
|
+
{ missing: 'token' });
|
|
22
|
+
}
|
|
23
|
+
this.transport = transport;
|
|
24
|
+
}
|
|
25
|
+
async _req(method, path, body) {
|
|
26
|
+
if (this.transport) return this.transport.request(method, path, body);
|
|
27
|
+
const res = await fetch(`https://api.github.com${path}`, {
|
|
28
|
+
method, headers: { Authorization: `Bearer ${this.token}`, Accept: 'application/vnd.github+json' },
|
|
29
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
30
|
+
});
|
|
31
|
+
const remainingHdr = res.headers.get('x-ratelimit-remaining');
|
|
32
|
+
const resetHdr = res.headers.get('x-ratelimit-reset');
|
|
33
|
+
if (res.status === 403 && remainingHdr !== null && Number(remainingHdr) === 0) {
|
|
34
|
+
const e = new Error('rate limited');
|
|
35
|
+
e.rateLimit = { resetMs: Number(resetHdr) * 1000 - Date.now() };
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
return { status: res.status, body: await res.json().catch(() => ({})), headers: res.headers };
|
|
39
|
+
}
|
|
40
|
+
async createIssue({ title, body, labels }) {
|
|
41
|
+
const r = await this._req('POST', `/repos/${this.repo}/issues`, { title, body, labels });
|
|
42
|
+
return r.body;
|
|
43
|
+
}
|
|
44
|
+
async getIssue(number) { return (await this._req('GET', `/repos/${this.repo}/issues/${number}`)).body; }
|
|
45
|
+
async updateIssue(number, patch) { return (await this._req('PATCH', `/repos/${this.repo}/issues/${number}`, patch)).body; }
|
|
46
|
+
async searchFeatureIssues() {
|
|
47
|
+
return (await this._req('GET', `/search/issues?q=repo:${this.repo}+label:compose-feature`)).body.items ?? [];
|
|
48
|
+
}
|
|
49
|
+
async addIssueComment(number, body) {
|
|
50
|
+
return (await this._req('POST', `/repos/${this.repo}/issues/${number}/comments`, { body })).body;
|
|
51
|
+
}
|
|
52
|
+
async listIssueComments(number) {
|
|
53
|
+
return (await this._req('GET', `/repos/${this.repo}/issues/${number}/comments`)).body ?? [];
|
|
54
|
+
}
|
|
55
|
+
async graphql(query, variables) {
|
|
56
|
+
const r = await this._req('POST', '/graphql', { query, variables });
|
|
57
|
+
return { data: r.body?.data, errors: r.body?.errors };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* GET /repos/:repo — lightweight probe to verify token has repo access.
|
|
62
|
+
* Returns { status, body }; does NOT throw on 4xx.
|
|
63
|
+
*/
|
|
64
|
+
async getRepo() {
|
|
65
|
+
return this._req('GET', `/repos/${this.repo}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* GET /repos/:repo/contents/:path?ref=:ref
|
|
70
|
+
* Returns { text, sha } where text is the decoded file content.
|
|
71
|
+
* If the file does not exist (404), returns { text: '', sha: null }.
|
|
72
|
+
*/
|
|
73
|
+
async getContents(path, ref) {
|
|
74
|
+
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
|
75
|
+
const r = await this._req('GET', `/repos/${this.repo}/contents/${path}${query}`);
|
|
76
|
+
if (r.status === 404) return { text: '', sha: null };
|
|
77
|
+
if (r.status !== 200) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`getContents ${path}@${ref}: HTTP ${r.status} ${JSON.stringify(r.body)?.slice(0, 200)}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const content = r.body?.content ?? '';
|
|
83
|
+
// GitHub returns base64 with embedded newlines — strip them before decoding.
|
|
84
|
+
const text = Buffer.from(content.replace(/\n/g, ''), 'base64').toString('utf-8');
|
|
85
|
+
const sha = r.body?.sha ?? null;
|
|
86
|
+
return { text, sha };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* PUT /repos/:repo/contents/:path
|
|
91
|
+
* Creates or updates a file.
|
|
92
|
+
* @param {string} path - File path in the repo
|
|
93
|
+
* @param {string} text - New file content (UTF-8)
|
|
94
|
+
* @param {{ sha: string|null, branch: string, message: string }} opts
|
|
95
|
+
* sha: current blob SHA (omit or pass null to create a new file)
|
|
96
|
+
* branch: target branch
|
|
97
|
+
* message: commit message
|
|
98
|
+
* On 409 (SHA conflict / optimistic-lock failure) throws with e.shaConflict = true.
|
|
99
|
+
*/
|
|
100
|
+
async putContents(path, text, { sha, branch, message }) {
|
|
101
|
+
const body = {
|
|
102
|
+
message,
|
|
103
|
+
content: Buffer.from(text, 'utf-8').toString('base64'),
|
|
104
|
+
branch,
|
|
105
|
+
};
|
|
106
|
+
if (sha) body.sha = sha;
|
|
107
|
+
const r = await this._req('PUT', `/repos/${this.repo}/contents/${path}`, body);
|
|
108
|
+
if (r.status === 409) {
|
|
109
|
+
const e = new Error(`putContents: SHA conflict for ${path}`);
|
|
110
|
+
e.shaConflict = true;
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
return r.body;
|
|
114
|
+
}
|
|
115
|
+
}
|