@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.
@@ -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 { readFeature, writeFeature, listFeatures, updateFeature } from './feature-json.js';
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
- const featuresDir = loadFeaturesDir(cwd);
103
- return maybeIdempotent({ ...args, cwd }, () => {
104
- const existing = readFeature(cwd, args.code, featuresDir);
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(cwd, args.phase, featuresDir);
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
- writeFeature(cwd, feature, featuresDir);
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 = writeRoadmap(cwd, { featuresDir });
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(cwd, phase, featuresDir) {
157
- const peers = _listFeatures(cwd, featuresDir).filter(f => f.phase === phase);
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
- function safeAppendEvent(cwd, event) {
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
- appendEvent(cwd, event);
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
- const featuresDir = loadFeaturesDir(cwd);
211
- return maybeIdempotent({ ...args, cwd }, () => {
212
- const feature = readFeature(cwd, args.code, featuresDir);
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
- const updates = { status: to };
234
- if (args.commit_sha) updates.commit_sha = args.commit_sha;
235
- updateFeature(cwd, args.code, updates, featuresDir);
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
- writeRoadmap(cwd, { featuresDir });
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 feature = readFeature(cwd, args.feature_code, featuresDir);
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
- updateFeature(cwd, args.feature_code, { artifacts }, featuresDir);
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
- const featuresDir = loadFeaturesDir(cwd);
456
- return maybeIdempotent({ ...args, cwd }, () => {
457
- const feature = readFeature(cwd, args.from_code, featuresDir);
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
- updateFeature(cwd, args.from_code, { links }, featuresDir);
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 featuresDir = loadFeaturesDir(cwd);
512
- const feature = readFeature(cwd, args.feature_code, featuresDir);
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 featuresDir = loadFeaturesDir(cwd);
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 = readFeature(cwd, args.feature_code, featuresDir);
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 = _listFeatures(cwd, featuresDir);
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;
@@ -46,22 +46,23 @@ function phaseStatus(features) {
46
46
  }
47
47
 
48
48
  /**
49
- * Generate ROADMAP.md content from feature.json files.
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} cwd - Project root
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.featuresDir] - Relative path to features dir
54
- * @param {string} [opts.projectName] - Project name for header
55
- * @param {string} [opts.projectDescription] - Project description for header
56
- * @returns {string} - Generated ROADMAP.md content
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 generateRoadmap(cwd, opts = {}) {
59
- const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
60
- const features = listFeatures(cwd, featuresDir);
61
+ export function generateRoadmapFromBase(baseText, features, opts = {}) {
62
+ const cwd = opts.cwd ?? '';
63
+ const featuresDir = opts.featuresDir ?? 'docs/features';
61
64
 
62
- // Read existing ROADMAP.md once: preamble + curated content for splice-back.
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 > 0) {
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
+ }