@lovenyberg/ove 0.2.1 → 0.3.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/README.md +10 -5
- package/bun.lock +63 -263
- package/config.example.json +13 -2
- package/docs/examples.md +28 -0
- package/docs/index.html +14 -9
- package/docs/plans/2026-02-22-repo-autodiscovery-design.md +98 -0
- package/docs/plans/2026-02-22-repo-autodiscovery-plan.md +826 -0
- package/package.json +2 -2
- package/src/adapters/debounce.ts +10 -0
- package/src/adapters/discord.ts +1 -11
- package/src/adapters/github.ts +12 -0
- package/src/adapters/http.ts +11 -3
- package/src/adapters/slack.ts +1 -11
- package/src/adapters/telegram.ts +37 -15
- package/src/adapters/whatsapp.ts +49 -11
- package/src/config.test.ts +70 -0
- package/src/config.ts +16 -4
- package/src/handlers.ts +512 -0
- package/src/index.ts +96 -491
- package/src/queue.ts +46 -10
- package/src/repo-registry.test.ts +130 -0
- package/src/repo-registry.ts +201 -0
- package/src/repos.ts +19 -5
- package/src/router.test.ts +125 -1
- package/src/router.ts +46 -3
- package/src/runner.ts +1 -0
- package/src/runners/claude.ts +7 -1
- package/src/runners/codex.ts +7 -1
- package/src/schedules.ts +13 -3
- package/src/sessions.ts +8 -2
- package/src/worker.ts +173 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
# Repo Auto-Discovery Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Scale Ove from manually configured repos to auto-discovering 50-100+ repos via GitHub, with on-demand cloning and Claude-powered repo resolution.
|
|
6
|
+
|
|
7
|
+
**Architecture:** New `RepoRegistry` class backed by SQLite `repos` table. GitHub sync via `gh repo list` runs on startup + interval. Config.json repos become overrides only. User wildcard `["*"]` grants access to all discovered repos. When a user message doesn't name a repo, the full repo list is injected into the prompt for Claude to resolve.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Bun + TypeScript, bun:sqlite, `gh` CLI for GitHub API, existing ClaudeRunner for repo resolution.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Create RepoRegistry — SQLite table + CRUD
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `src/repo-registry.ts`
|
|
17
|
+
- Test: `src/repo-registry.test.ts`
|
|
18
|
+
|
|
19
|
+
**Step 1: Write the failing tests**
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// src/repo-registry.test.ts
|
|
23
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
24
|
+
import { Database } from "bun:sqlite";
|
|
25
|
+
import { RepoRegistry } from "./repo-registry";
|
|
26
|
+
|
|
27
|
+
describe("RepoRegistry", () => {
|
|
28
|
+
let db: Database;
|
|
29
|
+
let registry: RepoRegistry;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
db = new Database(":memory:");
|
|
33
|
+
registry = new RepoRegistry(db);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("creates repos table on construction", () => {
|
|
37
|
+
const tables = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='repos'").all();
|
|
38
|
+
expect(tables.length).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("upserts and retrieves a repo", () => {
|
|
42
|
+
registry.upsert({
|
|
43
|
+
name: "my-app",
|
|
44
|
+
url: "git@github.com:user/my-app.git",
|
|
45
|
+
owner: "user",
|
|
46
|
+
defaultBranch: "main",
|
|
47
|
+
source: "github-sync",
|
|
48
|
+
});
|
|
49
|
+
const repo = registry.getByName("my-app");
|
|
50
|
+
expect(repo).not.toBeNull();
|
|
51
|
+
expect(repo!.url).toBe("git@github.com:user/my-app.git");
|
|
52
|
+
expect(repo!.source).toBe("github-sync");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("upsert updates existing repo", () => {
|
|
56
|
+
registry.upsert({ name: "my-app", url: "old-url", source: "config" });
|
|
57
|
+
registry.upsert({ name: "my-app", url: "new-url", source: "github-sync" });
|
|
58
|
+
const repo = registry.getByName("my-app");
|
|
59
|
+
expect(repo!.url).toBe("new-url");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns null for unknown repo", () => {
|
|
63
|
+
expect(registry.getByName("nope")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("lists all non-excluded repos", () => {
|
|
67
|
+
registry.upsert({ name: "a", url: "u1", source: "github-sync" });
|
|
68
|
+
registry.upsert({ name: "b", url: "u2", source: "github-sync" });
|
|
69
|
+
registry.upsert({ name: "c", url: "u3", source: "github-sync", excluded: true });
|
|
70
|
+
const all = registry.getAll();
|
|
71
|
+
expect(all.length).toBe(2);
|
|
72
|
+
expect(all.map(r => r.name).sort()).toEqual(["a", "b"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("lists all repo names", () => {
|
|
76
|
+
registry.upsert({ name: "x", url: "u1", source: "config" });
|
|
77
|
+
registry.upsert({ name: "y", url: "u2", source: "github-sync" });
|
|
78
|
+
const names = registry.getAllNames();
|
|
79
|
+
expect(names.sort()).toEqual(["x", "y"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("excludes a repo", () => {
|
|
83
|
+
registry.upsert({ name: "old", url: "u", source: "github-sync" });
|
|
84
|
+
registry.setExcluded("old", true);
|
|
85
|
+
expect(registry.getAll().length).toBe(0);
|
|
86
|
+
expect(registry.getByName("old")!.excluded).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("migrates config repos", () => {
|
|
90
|
+
const configRepos = {
|
|
91
|
+
"my-app": { url: "git@github.com:user/my-app.git", defaultBranch: "main" },
|
|
92
|
+
"infra": { url: "git@github.com:user/infra.git", defaultBranch: "develop" },
|
|
93
|
+
};
|
|
94
|
+
registry.migrateFromConfig(configRepos);
|
|
95
|
+
expect(registry.getAll().length).toBe(2);
|
|
96
|
+
const infra = registry.getByName("infra");
|
|
97
|
+
expect(infra!.defaultBranch).toBe("develop");
|
|
98
|
+
expect(infra!.source).toBe("config");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("migration does not overwrite github-sync repos", () => {
|
|
102
|
+
registry.upsert({ name: "my-app", url: "gh-url", source: "github-sync", defaultBranch: "main" });
|
|
103
|
+
registry.migrateFromConfig({
|
|
104
|
+
"my-app": { url: "config-url", defaultBranch: "main" },
|
|
105
|
+
});
|
|
106
|
+
expect(registry.getByName("my-app")!.url).toBe("gh-url");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Step 2: Run tests to verify they fail**
|
|
112
|
+
|
|
113
|
+
Run: `bun test src/repo-registry.test.ts`
|
|
114
|
+
Expected: FAIL — `repo-registry` module not found
|
|
115
|
+
|
|
116
|
+
**Step 3: Write the implementation**
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
// src/repo-registry.ts
|
|
120
|
+
import { Database } from "bun:sqlite";
|
|
121
|
+
|
|
122
|
+
export interface RepoRecord {
|
|
123
|
+
name: string;
|
|
124
|
+
url: string;
|
|
125
|
+
owner?: string;
|
|
126
|
+
defaultBranch: string;
|
|
127
|
+
source: string; // "github-sync" | "manual" | "config"
|
|
128
|
+
excluded: boolean;
|
|
129
|
+
lastSyncedAt: string | null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface RepoUpsertInput {
|
|
133
|
+
name: string;
|
|
134
|
+
url: string;
|
|
135
|
+
owner?: string;
|
|
136
|
+
defaultBranch?: string;
|
|
137
|
+
source: string;
|
|
138
|
+
excluded?: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class RepoRegistry {
|
|
142
|
+
private db: Database;
|
|
143
|
+
|
|
144
|
+
constructor(db: Database) {
|
|
145
|
+
this.db = db;
|
|
146
|
+
this.db.run(`
|
|
147
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
148
|
+
name TEXT PRIMARY KEY,
|
|
149
|
+
url TEXT NOT NULL,
|
|
150
|
+
owner TEXT,
|
|
151
|
+
default_branch TEXT DEFAULT 'main',
|
|
152
|
+
source TEXT NOT NULL,
|
|
153
|
+
excluded INTEGER DEFAULT 0,
|
|
154
|
+
last_synced_at TEXT
|
|
155
|
+
)
|
|
156
|
+
`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
upsert(input: RepoUpsertInput): void {
|
|
160
|
+
this.db.run(
|
|
161
|
+
`INSERT INTO repos (name, url, owner, default_branch, source, excluded, last_synced_at)
|
|
162
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
163
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
164
|
+
url = excluded.url,
|
|
165
|
+
owner = excluded.owner,
|
|
166
|
+
default_branch = excluded.default_branch,
|
|
167
|
+
source = excluded.source,
|
|
168
|
+
excluded = excluded.excluded,
|
|
169
|
+
last_synced_at = excluded.last_synced_at`,
|
|
170
|
+
[
|
|
171
|
+
input.name,
|
|
172
|
+
input.url,
|
|
173
|
+
input.owner || null,
|
|
174
|
+
input.defaultBranch || "main",
|
|
175
|
+
input.source,
|
|
176
|
+
input.excluded ? 1 : 0,
|
|
177
|
+
new Date().toISOString(),
|
|
178
|
+
]
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
getByName(name: string): RepoRecord | null {
|
|
183
|
+
const row = this.db.query(`SELECT * FROM repos WHERE name = ?`).get(name) as any;
|
|
184
|
+
return row ? this.rowToRecord(row) : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
getAll(): RepoRecord[] {
|
|
188
|
+
const rows = this.db.query(`SELECT * FROM repos WHERE excluded = 0 ORDER BY name`).all() as any[];
|
|
189
|
+
return rows.map(r => this.rowToRecord(r));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getAllNames(): string[] {
|
|
193
|
+
const rows = this.db.query(`SELECT name FROM repos WHERE excluded = 0 ORDER BY name`).all() as any[];
|
|
194
|
+
return rows.map(r => r.name);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
setExcluded(name: string, excluded: boolean): void {
|
|
198
|
+
this.db.run(`UPDATE repos SET excluded = ? WHERE name = ?`, [excluded ? 1 : 0, name]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
migrateFromConfig(configRepos: Record<string, { url: string; defaultBranch?: string }>): void {
|
|
202
|
+
for (const [name, repo] of Object.entries(configRepos)) {
|
|
203
|
+
// Don't overwrite repos already synced from GitHub
|
|
204
|
+
const existing = this.getByName(name);
|
|
205
|
+
if (existing && existing.source === "github-sync") continue;
|
|
206
|
+
|
|
207
|
+
this.upsert({
|
|
208
|
+
name,
|
|
209
|
+
url: repo.url,
|
|
210
|
+
defaultBranch: repo.defaultBranch || "main",
|
|
211
|
+
source: "config",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private rowToRecord(row: any): RepoRecord {
|
|
217
|
+
return {
|
|
218
|
+
name: row.name,
|
|
219
|
+
url: row.url,
|
|
220
|
+
owner: row.owner,
|
|
221
|
+
defaultBranch: row.default_branch,
|
|
222
|
+
source: row.source,
|
|
223
|
+
excluded: row.excluded === 1,
|
|
224
|
+
lastSyncedAt: row.last_synced_at,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Step 4: Run tests to verify they pass**
|
|
231
|
+
|
|
232
|
+
Run: `bun test src/repo-registry.test.ts`
|
|
233
|
+
Expected: All 9 tests PASS
|
|
234
|
+
|
|
235
|
+
**Step 5: Commit**
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
git add src/repo-registry.ts src/repo-registry.test.ts
|
|
239
|
+
git commit -m "feat: add RepoRegistry with SQLite-backed repo store"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
### Task 2: Add GitHub sync to RepoRegistry
|
|
245
|
+
|
|
246
|
+
**Files:**
|
|
247
|
+
- Modify: `src/repo-registry.ts`
|
|
248
|
+
- Test: `src/repo-registry.test.ts` (add sync tests)
|
|
249
|
+
|
|
250
|
+
**Step 1: Write the failing tests**
|
|
251
|
+
|
|
252
|
+
Add to `src/repo-registry.test.ts`:
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
describe("parseGhRepoLine", () => {
|
|
256
|
+
it("parses standard gh repo list output", () => {
|
|
257
|
+
const result = parseGhRepoLine("jacksoncage/ove\tMy app\tpublic\t2026-02-20T10:00:00Z");
|
|
258
|
+
expect(result).toEqual({ name: "ove", owner: "jacksoncage", fullName: "jacksoncage/ove" });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("returns null for empty line", () => {
|
|
262
|
+
expect(parseGhRepoLine("")).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`parseGhRepoLine` is exported from `repo-registry.ts`.
|
|
268
|
+
|
|
269
|
+
**Step 2: Run tests to verify they fail**
|
|
270
|
+
|
|
271
|
+
Run: `bun test src/repo-registry.test.ts`
|
|
272
|
+
Expected: FAIL — `parseGhRepoLine` not exported
|
|
273
|
+
|
|
274
|
+
**Step 3: Write the implementation**
|
|
275
|
+
|
|
276
|
+
Add to `src/repo-registry.ts`:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
import { logger } from "./logger";
|
|
280
|
+
|
|
281
|
+
export interface GhRepoParsed {
|
|
282
|
+
name: string;
|
|
283
|
+
owner: string;
|
|
284
|
+
fullName: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function parseGhRepoLine(line: string): GhRepoParsed | null {
|
|
288
|
+
const trimmed = line.trim();
|
|
289
|
+
if (!trimmed) return null;
|
|
290
|
+
// gh repo list output: owner/name\tdescription\tvisibility\tupdated_at
|
|
291
|
+
const fullName = trimmed.split("\t")[0];
|
|
292
|
+
if (!fullName || !fullName.includes("/")) return null;
|
|
293
|
+
const [owner, name] = fullName.split("/");
|
|
294
|
+
return { name, owner, fullName };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function syncGitHub(
|
|
298
|
+
registry: RepoRegistry,
|
|
299
|
+
orgs?: string[]
|
|
300
|
+
): Promise<number> {
|
|
301
|
+
let count = 0;
|
|
302
|
+
const targets = orgs && orgs.length > 0 ? orgs : [undefined];
|
|
303
|
+
|
|
304
|
+
for (const org of targets) {
|
|
305
|
+
try {
|
|
306
|
+
const args = ["repo", "list", "--limit", "500", "--no-archived"];
|
|
307
|
+
if (org) args.splice(2, 0, org);
|
|
308
|
+
|
|
309
|
+
const proc = Bun.spawn(["gh", ...args], {
|
|
310
|
+
stdout: "pipe",
|
|
311
|
+
stderr: "pipe",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const output = await new Response(proc.stdout).text();
|
|
315
|
+
const exitCode = await proc.exited;
|
|
316
|
+
|
|
317
|
+
if (exitCode !== 0) {
|
|
318
|
+
const stderr = await new Response(proc.stderr).text();
|
|
319
|
+
logger.warn("gh repo list failed", { org, error: stderr.slice(0, 200) });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const line of output.split("\n")) {
|
|
324
|
+
const parsed = parseGhRepoLine(line);
|
|
325
|
+
if (!parsed) continue;
|
|
326
|
+
|
|
327
|
+
registry.upsert({
|
|
328
|
+
name: parsed.name,
|
|
329
|
+
url: `git@github.com:${parsed.fullName}.git`,
|
|
330
|
+
owner: parsed.owner,
|
|
331
|
+
source: "github-sync",
|
|
332
|
+
});
|
|
333
|
+
count++;
|
|
334
|
+
}
|
|
335
|
+
} catch (err) {
|
|
336
|
+
logger.warn("github sync error", { org, error: String(err) });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
logger.info("github sync complete", { repos: count });
|
|
341
|
+
return count;
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**Step 4: Run tests to verify they pass**
|
|
346
|
+
|
|
347
|
+
Run: `bun test src/repo-registry.test.ts`
|
|
348
|
+
Expected: All tests PASS (sync function tested end-to-end in Task 6)
|
|
349
|
+
|
|
350
|
+
**Step 5: Commit**
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
git add src/repo-registry.ts src/repo-registry.test.ts
|
|
354
|
+
git commit -m "feat: add GitHub sync via gh repo list"
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
### Task 3: Update config.ts — GitHub config, wildcard auth, optional url
|
|
360
|
+
|
|
361
|
+
**Files:**
|
|
362
|
+
- Modify: `src/config.ts`
|
|
363
|
+
- Test: `src/config.test.ts` (create)
|
|
364
|
+
|
|
365
|
+
**Step 1: Write the failing tests**
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
// src/config.test.ts
|
|
369
|
+
import { describe, it, expect } from "bun:test";
|
|
370
|
+
import { isAuthorized, getUserRepos } from "./config";
|
|
371
|
+
import type { Config } from "./config";
|
|
372
|
+
|
|
373
|
+
function makeConfig(overrides: Partial<Config> = {}): Config {
|
|
374
|
+
return {
|
|
375
|
+
repos: {},
|
|
376
|
+
users: {},
|
|
377
|
+
claude: { maxTurns: 25 },
|
|
378
|
+
reposDir: "./repos",
|
|
379
|
+
...overrides,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
describe("wildcard auth", () => {
|
|
384
|
+
it("grants access to any repo with wildcard", () => {
|
|
385
|
+
const config = makeConfig({
|
|
386
|
+
users: { "tg:123": { name: "love", repos: ["*"] } },
|
|
387
|
+
});
|
|
388
|
+
expect(isAuthorized(config, "tg:123", "any-repo")).toBe(true);
|
|
389
|
+
expect(isAuthorized(config, "tg:123", "another-repo")).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("still works with explicit repo list", () => {
|
|
393
|
+
const config = makeConfig({
|
|
394
|
+
users: { "tg:123": { name: "love", repos: ["my-app"] } },
|
|
395
|
+
});
|
|
396
|
+
expect(isAuthorized(config, "tg:123", "my-app")).toBe(true);
|
|
397
|
+
expect(isAuthorized(config, "tg:123", "other")).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("denies unknown user", () => {
|
|
401
|
+
const config = makeConfig();
|
|
402
|
+
expect(isAuthorized(config, "unknown", "repo")).toBe(false);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("getUserRepos with wildcard", () => {
|
|
407
|
+
it("returns ['*'] when user has wildcard", () => {
|
|
408
|
+
const config = makeConfig({
|
|
409
|
+
users: { "tg:123": { name: "love", repos: ["*"] } },
|
|
410
|
+
});
|
|
411
|
+
expect(getUserRepos(config, "tg:123")).toEqual(["*"]);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe("github config", () => {
|
|
416
|
+
it("loadConfig parses github section", async () => {
|
|
417
|
+
// This is a structural test — just verify the type compiles
|
|
418
|
+
const config = makeConfig({
|
|
419
|
+
github: { syncInterval: 60000, orgs: ["my-org"] },
|
|
420
|
+
});
|
|
421
|
+
expect(config.github!.syncInterval).toBe(60000);
|
|
422
|
+
expect(config.github!.orgs).toEqual(["my-org"]);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Step 2: Run tests to verify they fail**
|
|
428
|
+
|
|
429
|
+
Run: `bun test src/config.test.ts`
|
|
430
|
+
Expected: FAIL — `github` not a valid key on Config type
|
|
431
|
+
|
|
432
|
+
**Step 3: Write the implementation**
|
|
433
|
+
|
|
434
|
+
Modify `src/config.ts`:
|
|
435
|
+
|
|
436
|
+
1. Add `GitHubConfig` interface:
|
|
437
|
+
```typescript
|
|
438
|
+
export interface GitHubConfig {
|
|
439
|
+
syncInterval?: number; // ms, default 1800000 (30 min)
|
|
440
|
+
orgs?: string[];
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
2. Add `github` to `Config`:
|
|
445
|
+
```typescript
|
|
446
|
+
export interface Config {
|
|
447
|
+
repos: Record<string, RepoConfig>;
|
|
448
|
+
users: Record<string, UserConfig>;
|
|
449
|
+
claude: { maxTurns: number };
|
|
450
|
+
reposDir: string;
|
|
451
|
+
mcpServers?: Record<string, McpServerConfig>;
|
|
452
|
+
cron?: CronTaskConfig[];
|
|
453
|
+
runner?: RunnerConfig;
|
|
454
|
+
github?: GitHubConfig;
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
3. Make `url` optional on `RepoConfig` (overrides-only repos won't have a url):
|
|
459
|
+
```typescript
|
|
460
|
+
export interface RepoConfig {
|
|
461
|
+
url?: string;
|
|
462
|
+
defaultBranch?: string;
|
|
463
|
+
runner?: RunnerConfig;
|
|
464
|
+
excluded?: boolean;
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
4. Update `loadConfig` to parse `github`:
|
|
469
|
+
```typescript
|
|
470
|
+
// In loadConfig return:
|
|
471
|
+
github: raw.github,
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
5. Update `isAuthorized` to support wildcard:
|
|
475
|
+
```typescript
|
|
476
|
+
export function isAuthorized(config: Config, platformUserId: string, repo?: string): boolean {
|
|
477
|
+
const user = config.users[platformUserId];
|
|
478
|
+
if (!user) return false;
|
|
479
|
+
if (!repo) return true;
|
|
480
|
+
return user.repos.includes("*") || user.repos.includes(repo);
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
6. Update `saveConfig` to preserve `github`:
|
|
485
|
+
```typescript
|
|
486
|
+
if (config.github) merged.github = config.github;
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
**Step 4: Run tests to verify they pass**
|
|
490
|
+
|
|
491
|
+
Run: `bun test src/config.test.ts`
|
|
492
|
+
Expected: All tests PASS
|
|
493
|
+
|
|
494
|
+
Also run existing tests: `bun test src/router.test.ts`
|
|
495
|
+
Expected: All PASS (no breaking changes)
|
|
496
|
+
|
|
497
|
+
**Step 5: Commit**
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
git add src/config.ts src/config.test.ts
|
|
501
|
+
git commit -m "feat: add GitHub config, wildcard auth, optional repo url"
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
### Task 4: Wire registry into index.ts — replace config.repos, background sync
|
|
507
|
+
|
|
508
|
+
**Files:**
|
|
509
|
+
- Modify: `src/index.ts`
|
|
510
|
+
|
|
511
|
+
This is the integration task. Changes:
|
|
512
|
+
|
|
513
|
+
**Step 1: Import and initialize RepoRegistry**
|
|
514
|
+
|
|
515
|
+
At the top of `src/index.ts`, add:
|
|
516
|
+
```typescript
|
|
517
|
+
import { RepoRegistry, syncGitHub } from "./repo-registry";
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
After `const schedules = new ScheduleStore(db);`, add:
|
|
521
|
+
```typescript
|
|
522
|
+
const repoRegistry = new RepoRegistry(db);
|
|
523
|
+
|
|
524
|
+
// Migrate existing config repos to SQLite
|
|
525
|
+
repoRegistry.migrateFromConfig(
|
|
526
|
+
Object.fromEntries(
|
|
527
|
+
Object.entries(config.repos)
|
|
528
|
+
.filter(([_, r]) => r.url)
|
|
529
|
+
.map(([name, r]) => [name, { url: r.url!, defaultBranch: r.defaultBranch }])
|
|
530
|
+
)
|
|
531
|
+
);
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
**Step 2: Add background sync function**
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
async function startGitHubSync() {
|
|
538
|
+
if (!config.github) return;
|
|
539
|
+
const interval = config.github.syncInterval || 1_800_000;
|
|
540
|
+
|
|
541
|
+
// Initial sync
|
|
542
|
+
await syncGitHub(repoRegistry, config.github.orgs);
|
|
543
|
+
|
|
544
|
+
// Recurring sync
|
|
545
|
+
setInterval(() => {
|
|
546
|
+
syncGitHub(repoRegistry, config.github!.orgs).catch((err) =>
|
|
547
|
+
logger.warn("github sync failed", { error: String(err) })
|
|
548
|
+
);
|
|
549
|
+
}, interval);
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
**Step 3: Update repo resolution in handleMessage**
|
|
554
|
+
|
|
555
|
+
Replace the current repo-fallback block (lines 387-401 in current `index.ts`) with registry-aware resolution:
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
// Need a repo for task commands
|
|
559
|
+
if (!parsed.repo) {
|
|
560
|
+
const userRepos = getUserRepos(config, msg.userId);
|
|
561
|
+
const hasWildcard = userRepos.includes("*");
|
|
562
|
+
|
|
563
|
+
if (!hasWildcard && userRepos.length === 1) {
|
|
564
|
+
parsed.repo = userRepos[0];
|
|
565
|
+
} else if (hasWildcard || userRepos.length > 1) {
|
|
566
|
+
// Resolve via Claude — inject repo list into prompt
|
|
567
|
+
const repoNames = hasWildcard
|
|
568
|
+
? repoRegistry.getAllNames()
|
|
569
|
+
: userRepos;
|
|
570
|
+
|
|
571
|
+
if (repoNames.length === 1) {
|
|
572
|
+
parsed.repo = repoNames[0];
|
|
573
|
+
} else if (repoNames.length === 0) {
|
|
574
|
+
const reply = "No repos discovered yet. Set one up with `init repo <name> <git-url>` or configure GitHub sync.";
|
|
575
|
+
await msg.reply(reply);
|
|
576
|
+
return;
|
|
577
|
+
} else {
|
|
578
|
+
// Let Claude resolve — inject available repos into the prompt
|
|
579
|
+
// The free-form prompt + repo list will let Claude pick or ask
|
|
580
|
+
parsed.args._availableRepos = repoNames;
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
const reply = "You don't have access to any repos yet. Set one up:\n`init repo <name> <git-url> [branch]`\nExample: `init repo my-app git@github.com:user/my-app.git`";
|
|
584
|
+
await msg.reply(reply);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**Step 4: Update repo config lookup in handleMessage**
|
|
591
|
+
|
|
592
|
+
Replace the hard check for `config.repos[parsed.repo]` (lines 410-414) to fall back to registry:
|
|
593
|
+
|
|
594
|
+
```typescript
|
|
595
|
+
// Check repo exists — config overrides or registry
|
|
596
|
+
if (parsed.repo) {
|
|
597
|
+
const repoConfig = config.repos[parsed.repo];
|
|
598
|
+
const registryRepo = repoRegistry.getByName(parsed.repo);
|
|
599
|
+
|
|
600
|
+
if (!repoConfig && !registryRepo) {
|
|
601
|
+
await msg.reply(`Never heard of ${parsed.repo}. Check the config or run GitHub sync.`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
**Step 5: Update processTask to resolve repo URL from registry**
|
|
608
|
+
|
|
609
|
+
In `processTask`, replace the `config.repos[task.repo]` lookup with a function that merges config overrides + registry:
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
function getRepoInfo(repoName: string): { url: string; defaultBranch: string } | null {
|
|
613
|
+
const configRepo = config.repos[repoName];
|
|
614
|
+
const registryRepo = repoRegistry.getByName(repoName);
|
|
615
|
+
|
|
616
|
+
if (!configRepo && !registryRepo) return null;
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
url: configRepo?.url || registryRepo?.url || "",
|
|
620
|
+
defaultBranch: configRepo?.defaultBranch || registryRepo?.defaultBranch || "main",
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
Use this in `processTask` instead of `config.repos[task.repo]`.
|
|
626
|
+
|
|
627
|
+
**Step 6: Inject repo list into prompt when Claude needs to resolve**
|
|
628
|
+
|
|
629
|
+
In `buildContextualPrompt` call (or just before it), when `parsed.args._availableRepos` is set and `parsed.repo` is still undefined, prepend the repo list:
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
// Before building prompt, inject repo list for Claude resolution
|
|
633
|
+
if (parsed.args._availableRepos && !parsed.repo) {
|
|
634
|
+
const repoList = parsed.args._availableRepos.join(", ");
|
|
635
|
+
const resolvePrefix = `Available repos: ${repoList}\n\nThe user hasn't specified which repo. Based on their message, determine the correct repo. If unclear, ask them which repo they mean.\n\n`;
|
|
636
|
+
parsed.rawText = resolvePrefix + parsed.rawText;
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
**Step 7: Start GitHub sync in main()**
|
|
641
|
+
|
|
642
|
+
In `main()`, after stale task reset, before adapter startup:
|
|
643
|
+
|
|
644
|
+
```typescript
|
|
645
|
+
// Start GitHub repo sync (non-blocking)
|
|
646
|
+
startGitHubSync().catch((err) =>
|
|
647
|
+
logger.warn("initial github sync failed", { error: String(err) })
|
|
648
|
+
);
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
**Step 8: Update handleEvent similarly**
|
|
652
|
+
|
|
653
|
+
Apply the same registry-aware resolution to `handleEvent` (the event adapter code path).
|
|
654
|
+
|
|
655
|
+
**Step 9: Run all tests**
|
|
656
|
+
|
|
657
|
+
Run: `bun test`
|
|
658
|
+
Expected: All existing tests PASS
|
|
659
|
+
|
|
660
|
+
**Step 10: Commit**
|
|
661
|
+
|
|
662
|
+
```bash
|
|
663
|
+
git add src/index.ts
|
|
664
|
+
git commit -m "feat: wire RepoRegistry into index.ts with GitHub sync and Claude resolution"
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
### Task 5: Update schedule handling for wildcard repos
|
|
670
|
+
|
|
671
|
+
**Files:**
|
|
672
|
+
- Modify: `src/index.ts` (schedule section in handleMessage)
|
|
673
|
+
|
|
674
|
+
**Step 1: Update schedule repo resolution**
|
|
675
|
+
|
|
676
|
+
The schedule handler at line 264 uses `getUserRepos(config, msg.userId)` to get the repo list. Update it to resolve wildcard:
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
if (parsed.type === "schedule") {
|
|
680
|
+
await msg.updateStatus("Parsing your schedule...");
|
|
681
|
+
const rawRepos = getUserRepos(config, msg.userId);
|
|
682
|
+
const userRepos = rawRepos.includes("*") ? repoRegistry.getAllNames() : rawRepos;
|
|
683
|
+
// ... rest unchanged
|
|
684
|
+
}
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
**Step 2: Run tests**
|
|
688
|
+
|
|
689
|
+
Run: `bun test`
|
|
690
|
+
Expected: All PASS
|
|
691
|
+
|
|
692
|
+
**Step 3: Commit**
|
|
693
|
+
|
|
694
|
+
```bash
|
|
695
|
+
git add src/index.ts
|
|
696
|
+
git commit -m "feat: support wildcard repos in schedule handling"
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
---
|
|
700
|
+
|
|
701
|
+
### Task 6: Integration tests + config.example.json update
|
|
702
|
+
|
|
703
|
+
**Files:**
|
|
704
|
+
- Modify: `config.example.json`
|
|
705
|
+
- Test: `src/repo-registry.test.ts` (add integration-style tests)
|
|
706
|
+
|
|
707
|
+
**Step 1: Add integration tests for sync + config migration flow**
|
|
708
|
+
|
|
709
|
+
Add to `src/repo-registry.test.ts`:
|
|
710
|
+
|
|
711
|
+
```typescript
|
|
712
|
+
describe("config + registry integration", () => {
|
|
713
|
+
it("config repos + registry merge correctly", () => {
|
|
714
|
+
const db = new Database(":memory:");
|
|
715
|
+
const registry = new RepoRegistry(db);
|
|
716
|
+
|
|
717
|
+
// Simulate GitHub sync adding repos
|
|
718
|
+
registry.upsert({ name: "api", url: "git@github.com:org/api.git", owner: "org", source: "github-sync" });
|
|
719
|
+
registry.upsert({ name: "web", url: "git@github.com:org/web.git", owner: "org", source: "github-sync" });
|
|
720
|
+
|
|
721
|
+
// Simulate config migration (manual repo + override)
|
|
722
|
+
registry.migrateFromConfig({
|
|
723
|
+
"legacy": { url: "git@github.com:me/legacy.git", defaultBranch: "develop" },
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// All three repos exist
|
|
727
|
+
expect(registry.getAllNames().sort()).toEqual(["api", "legacy", "web"]);
|
|
728
|
+
|
|
729
|
+
// Excluding a repo hides it from getAll but not getByName
|
|
730
|
+
registry.setExcluded("legacy", true);
|
|
731
|
+
expect(registry.getAllNames().sort()).toEqual(["api", "web"]);
|
|
732
|
+
expect(registry.getByName("legacy")).not.toBeNull();
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
**Step 2: Run tests**
|
|
738
|
+
|
|
739
|
+
Run: `bun test`
|
|
740
|
+
Expected: All PASS
|
|
741
|
+
|
|
742
|
+
**Step 3: Update config.example.json**
|
|
743
|
+
|
|
744
|
+
```json
|
|
745
|
+
{
|
|
746
|
+
"repos": {
|
|
747
|
+
"my-app": {
|
|
748
|
+
"url": "git@github.com:user/my-app.git",
|
|
749
|
+
"defaultBranch": "main"
|
|
750
|
+
},
|
|
751
|
+
"infra": {
|
|
752
|
+
"runner": { "name": "codex" },
|
|
753
|
+
"defaultBranch": "develop"
|
|
754
|
+
},
|
|
755
|
+
"old-legacy": {
|
|
756
|
+
"excluded": true
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
"users": {
|
|
760
|
+
"slack:U12345678": {
|
|
761
|
+
"name": "love",
|
|
762
|
+
"repos": ["my-app"]
|
|
763
|
+
},
|
|
764
|
+
"telegram:123456789": {
|
|
765
|
+
"name": "love",
|
|
766
|
+
"repos": ["*"]
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
"claude": {
|
|
770
|
+
"maxTurns": 25
|
|
771
|
+
},
|
|
772
|
+
"runner": {
|
|
773
|
+
"name": "claude"
|
|
774
|
+
},
|
|
775
|
+
"github": {
|
|
776
|
+
"syncInterval": 1800000,
|
|
777
|
+
"orgs": ["my-org"]
|
|
778
|
+
},
|
|
779
|
+
"mcpServers": {
|
|
780
|
+
"filesystem": {
|
|
781
|
+
"command": "npx",
|
|
782
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/YOUR_USER"]
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
"cron": [
|
|
786
|
+
{
|
|
787
|
+
"schedule": "0 9 * * 1-5",
|
|
788
|
+
"repo": "my-app",
|
|
789
|
+
"prompt": "Review all open PRs and post review comments.",
|
|
790
|
+
"userId": "slack:U12345678"
|
|
791
|
+
}
|
|
792
|
+
]
|
|
793
|
+
}
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
**Step 4: Commit**
|
|
797
|
+
|
|
798
|
+
```bash
|
|
799
|
+
git add src/repo-registry.test.ts config.example.json
|
|
800
|
+
git commit -m "feat: integration tests and updated config example for auto-discovery"
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
805
|
+
### Task 7: Final verification and docs
|
|
806
|
+
|
|
807
|
+
**Step 1: Run full test suite**
|
|
808
|
+
|
|
809
|
+
Run: `bun test`
|
|
810
|
+
Expected: All tests PASS
|
|
811
|
+
|
|
812
|
+
**Step 2: Manual smoke test**
|
|
813
|
+
|
|
814
|
+
Run: `bun run src/index.ts`
|
|
815
|
+
Expected:
|
|
816
|
+
- Ove starts without errors
|
|
817
|
+
- If `github` config is set, logs `github sync complete`
|
|
818
|
+
- Config repos appear in registry
|
|
819
|
+
- Wildcard user can target any discovered repo
|
|
820
|
+
|
|
821
|
+
**Step 3: Commit everything and push**
|
|
822
|
+
|
|
823
|
+
```bash
|
|
824
|
+
git add -A
|
|
825
|
+
git commit -m "feat: repo auto-discovery via GitHub sync with Claude resolution"
|
|
826
|
+
```
|