@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
package/src/queue.ts
CHANGED
|
@@ -19,6 +19,18 @@ export interface Task {
|
|
|
19
19
|
completedAt: string | null;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
interface TaskRow {
|
|
23
|
+
id: string;
|
|
24
|
+
user_id: string;
|
|
25
|
+
repo: string;
|
|
26
|
+
prompt: string;
|
|
27
|
+
status: string;
|
|
28
|
+
result: string | null;
|
|
29
|
+
task_type: string | null;
|
|
30
|
+
created_at: string;
|
|
31
|
+
completed_at: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
export class TaskQueue {
|
|
23
35
|
private db: Database;
|
|
24
36
|
|
|
@@ -38,10 +50,9 @@ export class TaskQueue {
|
|
|
38
50
|
)
|
|
39
51
|
`);
|
|
40
52
|
// Migration: add task_type column if missing (backward compat)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// Column already exists
|
|
53
|
+
const columns = this.db.query("PRAGMA table_info(tasks)").all() as { name: string }[];
|
|
54
|
+
if (!columns.some(c => c.name === "task_type")) {
|
|
55
|
+
this.db.run("ALTER TABLE tasks ADD COLUMN task_type TEXT");
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
|
|
@@ -64,7 +75,7 @@ export class TaskQueue {
|
|
|
64
75
|
ORDER BY created_at ASC
|
|
65
76
|
LIMIT 1`
|
|
66
77
|
)
|
|
67
|
-
.get() as
|
|
78
|
+
.get() as TaskRow;
|
|
68
79
|
|
|
69
80
|
if (!row) return null;
|
|
70
81
|
|
|
@@ -88,7 +99,7 @@ export class TaskQueue {
|
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
get(id: string): Task | null {
|
|
91
|
-
const row = this.db.query(`SELECT * FROM tasks WHERE id = ?`).get(id) as
|
|
102
|
+
const row = this.db.query(`SELECT * FROM tasks WHERE id = ?`).get(id) as TaskRow;
|
|
92
103
|
return row ? this.rowToTask(row) : null;
|
|
93
104
|
}
|
|
94
105
|
|
|
@@ -97,7 +108,7 @@ export class TaskQueue {
|
|
|
97
108
|
.query(
|
|
98
109
|
`SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`
|
|
99
110
|
)
|
|
100
|
-
.all(userId, limit) as
|
|
111
|
+
.all(userId, limit) as TaskRow[];
|
|
101
112
|
return rows.map((r) => this.rowToTask(r));
|
|
102
113
|
}
|
|
103
114
|
|
|
@@ -111,17 +122,42 @@ export class TaskQueue {
|
|
|
111
122
|
COUNT(*) FILTER (WHERE status = 'failed') as failed
|
|
112
123
|
FROM tasks`
|
|
113
124
|
)
|
|
114
|
-
.get() as
|
|
125
|
+
.get() as { pending: number; running: number; completed: number; failed: number };
|
|
115
126
|
return row;
|
|
116
127
|
}
|
|
117
128
|
|
|
118
|
-
|
|
129
|
+
listActive(limit: number = 20): Task[] {
|
|
130
|
+
const rows = this.db
|
|
131
|
+
.query(
|
|
132
|
+
`SELECT * FROM tasks WHERE status IN ('running', 'pending') ORDER BY created_at ASC LIMIT ?`
|
|
133
|
+
)
|
|
134
|
+
.all(limit) as TaskRow[];
|
|
135
|
+
return rows.map((r) => this.rowToTask(r));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
cancel(id: string): boolean {
|
|
139
|
+
const result = this.db.run(
|
|
140
|
+
`UPDATE tasks SET status = 'failed', result = 'Cancelled', completed_at = ? WHERE id = ? AND status IN ('running', 'pending')`,
|
|
141
|
+
[new Date().toISOString(), id]
|
|
142
|
+
);
|
|
143
|
+
return result.changes > 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
resetStale(): number {
|
|
147
|
+
const result = this.db.run(
|
|
148
|
+
`UPDATE tasks SET status = 'failed', result = 'Interrupted — process restarted', completed_at = ? WHERE status = 'running'`,
|
|
149
|
+
[new Date().toISOString()]
|
|
150
|
+
);
|
|
151
|
+
return result.changes;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private rowToTask(row: TaskRow): Task {
|
|
119
155
|
return {
|
|
120
156
|
id: row.id,
|
|
121
157
|
userId: row.user_id,
|
|
122
158
|
repo: row.repo,
|
|
123
159
|
prompt: row.prompt,
|
|
124
|
-
status: row.status,
|
|
160
|
+
status: row.status as "pending" | "running" | "completed" | "failed",
|
|
125
161
|
result: row.result,
|
|
126
162
|
taskType: row.task_type || null,
|
|
127
163
|
createdAt: row.created_at,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { RepoRegistry, parseGhRepoLine } from "./repo-registry";
|
|
4
|
+
|
|
5
|
+
describe("RepoRegistry", () => {
|
|
6
|
+
let db: Database;
|
|
7
|
+
let registry: RepoRegistry;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
db = new Database(":memory:");
|
|
11
|
+
registry = new RepoRegistry(db);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("creates repos table on construction", () => {
|
|
15
|
+
const tables = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='repos'").all();
|
|
16
|
+
expect(tables.length).toBe(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("upserts and retrieves a repo", () => {
|
|
20
|
+
registry.upsert({
|
|
21
|
+
name: "my-app",
|
|
22
|
+
url: "git@github.com:user/my-app.git",
|
|
23
|
+
owner: "user",
|
|
24
|
+
defaultBranch: "main",
|
|
25
|
+
source: "github-sync",
|
|
26
|
+
});
|
|
27
|
+
const repo = registry.getByName("my-app");
|
|
28
|
+
expect(repo).not.toBeNull();
|
|
29
|
+
expect(repo!.url).toBe("git@github.com:user/my-app.git");
|
|
30
|
+
expect(repo!.source).toBe("github-sync");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("upsert updates existing repo", () => {
|
|
34
|
+
registry.upsert({ name: "my-app", url: "old-url", source: "config" });
|
|
35
|
+
registry.upsert({ name: "my-app", url: "new-url", source: "github-sync" });
|
|
36
|
+
const repo = registry.getByName("my-app");
|
|
37
|
+
expect(repo!.url).toBe("new-url");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns null for unknown repo", () => {
|
|
41
|
+
expect(registry.getByName("nope")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("lists all non-excluded repos", () => {
|
|
45
|
+
registry.upsert({ name: "a", url: "u1", source: "github-sync" });
|
|
46
|
+
registry.upsert({ name: "b", url: "u2", source: "github-sync" });
|
|
47
|
+
registry.upsert({ name: "c", url: "u3", source: "github-sync", excluded: true });
|
|
48
|
+
const all = registry.getAll();
|
|
49
|
+
expect(all.length).toBe(2);
|
|
50
|
+
expect(all.map(r => r.name).sort()).toEqual(["a", "b"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("lists all repo names", () => {
|
|
54
|
+
registry.upsert({ name: "x", url: "u1", source: "config" });
|
|
55
|
+
registry.upsert({ name: "y", url: "u2", source: "github-sync" });
|
|
56
|
+
const names = registry.getAllNames();
|
|
57
|
+
expect(names.sort()).toEqual(["x", "y"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("excludes a repo", () => {
|
|
61
|
+
registry.upsert({ name: "old", url: "u", source: "github-sync" });
|
|
62
|
+
registry.setExcluded("old", true);
|
|
63
|
+
expect(registry.getAll().length).toBe(0);
|
|
64
|
+
expect(registry.getByName("old")!.excluded).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("migrates config repos", () => {
|
|
68
|
+
const configRepos = {
|
|
69
|
+
"my-app": { url: "git@github.com:user/my-app.git", defaultBranch: "main" },
|
|
70
|
+
"infra": { url: "git@github.com:user/infra.git", defaultBranch: "develop" },
|
|
71
|
+
};
|
|
72
|
+
registry.migrateFromConfig(configRepos);
|
|
73
|
+
expect(registry.getAll().length).toBe(2);
|
|
74
|
+
const infra = registry.getByName("infra");
|
|
75
|
+
expect(infra!.defaultBranch).toBe("develop");
|
|
76
|
+
expect(infra!.source).toBe("config");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("migration does not overwrite github-sync repos", () => {
|
|
80
|
+
registry.upsert({ name: "my-app", url: "gh-url", source: "github-sync", defaultBranch: "main" });
|
|
81
|
+
registry.migrateFromConfig({
|
|
82
|
+
"my-app": { url: "config-url", defaultBranch: "main" },
|
|
83
|
+
});
|
|
84
|
+
expect(registry.getByName("my-app")!.url).toBe("gh-url");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("parseGhRepoLine", () => {
|
|
89
|
+
it("parses standard gh repo list output", () => {
|
|
90
|
+
const result = parseGhRepoLine("jacksoncage/ove\tMy app\tpublic\t2026-02-20T10:00:00Z");
|
|
91
|
+
expect(result).toEqual({ name: "ove", owner: "jacksoncage", fullName: "jacksoncage/ove" });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("parses line with no description", () => {
|
|
95
|
+
const result = parseGhRepoLine("org/repo-name\t\tprivate\t2026-01-01T00:00:00Z");
|
|
96
|
+
expect(result).toEqual({ name: "repo-name", owner: "org", fullName: "org/repo-name" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns null for empty line", () => {
|
|
100
|
+
expect(parseGhRepoLine("")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns null for line without slash", () => {
|
|
104
|
+
expect(parseGhRepoLine("no-slash-here")).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("config + registry integration", () => {
|
|
109
|
+
it("config repos + registry merge correctly", () => {
|
|
110
|
+
const db = new Database(":memory:");
|
|
111
|
+
const registry = new RepoRegistry(db);
|
|
112
|
+
|
|
113
|
+
// Simulate GitHub sync adding repos
|
|
114
|
+
registry.upsert({ name: "api", url: "git@github.com:org/api.git", owner: "org", source: "github-sync" });
|
|
115
|
+
registry.upsert({ name: "web", url: "git@github.com:org/web.git", owner: "org", source: "github-sync" });
|
|
116
|
+
|
|
117
|
+
// Simulate config migration (manual repo)
|
|
118
|
+
registry.migrateFromConfig({
|
|
119
|
+
"legacy": { url: "git@github.com:me/legacy.git", defaultBranch: "develop" },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// All three repos exist
|
|
123
|
+
expect(registry.getAllNames().sort()).toEqual(["api", "legacy", "web"]);
|
|
124
|
+
|
|
125
|
+
// Excluding a repo hides it from getAll but not getByName
|
|
126
|
+
registry.setExcluded("legacy", true);
|
|
127
|
+
expect(registry.getAllNames().sort()).toEqual(["api", "web"]);
|
|
128
|
+
expect(registry.getByName("legacy")).not.toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { logger } from "./logger";
|
|
3
|
+
|
|
4
|
+
export interface RepoRecord {
|
|
5
|
+
name: string;
|
|
6
|
+
url: string;
|
|
7
|
+
owner: string | null;
|
|
8
|
+
defaultBranch: string;
|
|
9
|
+
source: string;
|
|
10
|
+
excluded: boolean;
|
|
11
|
+
lastSyncedAt: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RepoRow {
|
|
15
|
+
name: string;
|
|
16
|
+
url: string;
|
|
17
|
+
owner: string | null;
|
|
18
|
+
default_branch: string;
|
|
19
|
+
source: string;
|
|
20
|
+
excluded: number;
|
|
21
|
+
last_synced_at: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RepoUpsertInput {
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
owner?: string;
|
|
28
|
+
defaultBranch?: string;
|
|
29
|
+
source: string;
|
|
30
|
+
excluded?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class RepoRegistry {
|
|
34
|
+
private db: Database;
|
|
35
|
+
|
|
36
|
+
constructor(db: Database) {
|
|
37
|
+
this.db = db;
|
|
38
|
+
this.db.run(`
|
|
39
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
40
|
+
name TEXT PRIMARY KEY,
|
|
41
|
+
url TEXT NOT NULL,
|
|
42
|
+
owner TEXT,
|
|
43
|
+
default_branch TEXT DEFAULT 'main',
|
|
44
|
+
source TEXT NOT NULL,
|
|
45
|
+
excluded INTEGER DEFAULT 0,
|
|
46
|
+
last_synced_at TEXT
|
|
47
|
+
)
|
|
48
|
+
`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
upsert(input: RepoUpsertInput): void {
|
|
52
|
+
this.db.run(
|
|
53
|
+
`INSERT INTO repos (name, url, owner, default_branch, source, excluded, last_synced_at)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
55
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
56
|
+
url = excluded.url,
|
|
57
|
+
owner = excluded.owner,
|
|
58
|
+
default_branch = excluded.default_branch,
|
|
59
|
+
source = excluded.source,
|
|
60
|
+
excluded = excluded.excluded,
|
|
61
|
+
last_synced_at = excluded.last_synced_at`,
|
|
62
|
+
[
|
|
63
|
+
input.name,
|
|
64
|
+
input.url,
|
|
65
|
+
input.owner || null,
|
|
66
|
+
input.defaultBranch || "main",
|
|
67
|
+
input.source,
|
|
68
|
+
input.excluded ? 1 : 0,
|
|
69
|
+
new Date().toISOString(),
|
|
70
|
+
]
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getByName(name: string): RepoRecord | null {
|
|
75
|
+
const row = this.db.query(`SELECT * FROM repos WHERE name = ?`).get(name) as RepoRow;
|
|
76
|
+
return row ? this.rowToRecord(row) : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getAll(): RepoRecord[] {
|
|
80
|
+
const rows = this.db.query(`SELECT * FROM repos WHERE excluded = 0 ORDER BY name`).all() as RepoRow[];
|
|
81
|
+
return rows.map(r => this.rowToRecord(r));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getAllNames(): string[] {
|
|
85
|
+
const rows = this.db.query(`SELECT name FROM repos WHERE excluded = 0 ORDER BY name`).all() as { name: string }[];
|
|
86
|
+
return rows.map(r => r.name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setExcluded(name: string, excluded: boolean): void {
|
|
90
|
+
this.db.run(`UPDATE repos SET excluded = ? WHERE name = ?`, [excluded ? 1 : 0, name]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
migrateFromConfig(configRepos: Record<string, { url: string; defaultBranch?: string }>): void {
|
|
94
|
+
for (const [name, repo] of Object.entries(configRepos)) {
|
|
95
|
+
const existing = this.getByName(name);
|
|
96
|
+
if (existing && existing.source === "github-sync") continue;
|
|
97
|
+
|
|
98
|
+
this.upsert({
|
|
99
|
+
name,
|
|
100
|
+
url: repo.url,
|
|
101
|
+
defaultBranch: repo.defaultBranch || "main",
|
|
102
|
+
source: "config",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private rowToRecord(row: RepoRow): RepoRecord {
|
|
108
|
+
return {
|
|
109
|
+
name: row.name,
|
|
110
|
+
url: row.url,
|
|
111
|
+
owner: row.owner,
|
|
112
|
+
defaultBranch: row.default_branch,
|
|
113
|
+
source: row.source,
|
|
114
|
+
excluded: row.excluded === 1,
|
|
115
|
+
lastSyncedAt: row.last_synced_at,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface GhRepoParsed {
|
|
121
|
+
name: string;
|
|
122
|
+
owner: string;
|
|
123
|
+
fullName: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function parseGhRepoLine(line: string): GhRepoParsed | null {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
if (!trimmed) return null;
|
|
129
|
+
// gh repo list output: owner/name\tdescription\tvisibility\tupdated_at
|
|
130
|
+
const fullName = trimmed.split("\t")[0];
|
|
131
|
+
if (!fullName || !fullName.includes("/")) return null;
|
|
132
|
+
const [owner, ...rest] = fullName.split("/");
|
|
133
|
+
const name = rest.join("/"); // handle potential edge cases
|
|
134
|
+
return { name, owner, fullName };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface GhRepoResponse {
|
|
138
|
+
name: string;
|
|
139
|
+
owner?: { login: string };
|
|
140
|
+
defaultBranchRef?: { name: string };
|
|
141
|
+
isArchived: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function syncGitHub(
|
|
145
|
+
registry: RepoRegistry,
|
|
146
|
+
orgs?: string[]
|
|
147
|
+
): Promise<number> {
|
|
148
|
+
let count = 0;
|
|
149
|
+
const targets = orgs && orgs.length > 0 ? orgs : [undefined];
|
|
150
|
+
|
|
151
|
+
for (const org of targets) {
|
|
152
|
+
try {
|
|
153
|
+
const args = ["repo", "list", "--json", "name,owner,defaultBranchRef,isArchived", "--limit", "500"];
|
|
154
|
+
if (org) args.splice(2, 0, org);
|
|
155
|
+
|
|
156
|
+
const proc = Bun.spawn(["gh", ...args], {
|
|
157
|
+
stdout: "pipe",
|
|
158
|
+
stderr: "pipe",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const output = await new Response(proc.stdout).text();
|
|
162
|
+
const exitCode = await proc.exited;
|
|
163
|
+
|
|
164
|
+
if (exitCode !== 0) {
|
|
165
|
+
const stderr = await new Response(proc.stderr).text();
|
|
166
|
+
logger.warn("gh repo list failed", { org, error: stderr.slice(0, 200) });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let repos: GhRepoResponse[];
|
|
171
|
+
try {
|
|
172
|
+
repos = JSON.parse(output);
|
|
173
|
+
} catch {
|
|
174
|
+
// JSON parsing failed — log and skip this org
|
|
175
|
+
logger.warn("gh repo list returned invalid JSON", { org });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const repo of repos) {
|
|
180
|
+
if (repo.isArchived) continue;
|
|
181
|
+
const owner = repo.owner?.login || org || "";
|
|
182
|
+
const name = repo.name;
|
|
183
|
+
const defaultBranch = repo.defaultBranchRef?.name || "main";
|
|
184
|
+
|
|
185
|
+
registry.upsert({
|
|
186
|
+
name,
|
|
187
|
+
url: `git@github.com:${owner}/${name}.git`,
|
|
188
|
+
owner,
|
|
189
|
+
defaultBranch,
|
|
190
|
+
source: "github-sync",
|
|
191
|
+
});
|
|
192
|
+
count++;
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
logger.warn("github sync error", { org, error: String(err) });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
logger.info("github sync complete", { repos: count });
|
|
200
|
+
return count;
|
|
201
|
+
}
|
package/src/repos.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
2
|
import { logger } from "./logger";
|
|
3
3
|
|
|
4
|
+
const GIT_TIMEOUT = 60_000; // 60 seconds
|
|
5
|
+
const GIT_ENV = {
|
|
6
|
+
...process.env,
|
|
7
|
+
GIT_SSH_COMMAND: "ssh -o ConnectTimeout=10 -o BatchMode=yes",
|
|
8
|
+
};
|
|
9
|
+
|
|
4
10
|
export class RepoManager {
|
|
5
11
|
constructor(private reposDir: string) {}
|
|
6
12
|
|
|
@@ -23,10 +29,14 @@ export class RepoManager {
|
|
|
23
29
|
const proc = Bun.spawn(["git", "clone", url, path], {
|
|
24
30
|
stdout: "pipe",
|
|
25
31
|
stderr: "pipe",
|
|
32
|
+
env: GIT_ENV,
|
|
26
33
|
});
|
|
27
|
-
const exitCode = await
|
|
34
|
+
const exitCode = await Promise.race([
|
|
35
|
+
proc.exited,
|
|
36
|
+
Bun.sleep(GIT_TIMEOUT).then(() => { proc.kill(); return -1; }),
|
|
37
|
+
]);
|
|
28
38
|
if (exitCode !== 0) {
|
|
29
|
-
const stderr = await new Response(proc.stderr).text();
|
|
39
|
+
const stderr = exitCode === -1 ? "git clone timed out" : await new Response(proc.stderr).text();
|
|
30
40
|
throw new Error(`git clone failed: ${stderr}`);
|
|
31
41
|
}
|
|
32
42
|
}
|
|
@@ -38,11 +48,15 @@ export class RepoManager {
|
|
|
38
48
|
cwd: path,
|
|
39
49
|
stdout: "pipe",
|
|
40
50
|
stderr: "pipe",
|
|
51
|
+
env: GIT_ENV,
|
|
41
52
|
});
|
|
42
|
-
const exitCode = await
|
|
53
|
+
const exitCode = await Promise.race([
|
|
54
|
+
proc.exited,
|
|
55
|
+
Bun.sleep(GIT_TIMEOUT).then(() => { proc.kill(); return -1; }),
|
|
56
|
+
]);
|
|
43
57
|
if (exitCode !== 0) {
|
|
44
|
-
const
|
|
45
|
-
logger.warn("git pull failed", { repo: repoName, error:
|
|
58
|
+
const msg = exitCode === -1 ? "git pull timed out" : await new Response(proc.stderr).text();
|
|
59
|
+
logger.warn("git pull failed", { repo: repoName, error: msg });
|
|
46
60
|
}
|
|
47
61
|
}
|
|
48
62
|
|
package/src/router.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { parseMessage, buildPrompt } from "./router";
|
|
2
|
+
import { parseMessage, buildPrompt, buildCronPrompt } from "./router";
|
|
3
3
|
|
|
4
4
|
describe("parseMessage", () => {
|
|
5
5
|
it("parses PR review command", () => {
|
|
@@ -124,6 +124,29 @@ describe("parseMessage", () => {
|
|
|
124
124
|
expect(result.args.scheduleId).toBe(1);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it("parses 'every 30 min' as schedule", () => {
|
|
128
|
+
const result = parseMessage("simplify code and create a PR every 30 min on infra-salming-ai");
|
|
129
|
+
expect(result.type).toBe("schedule");
|
|
130
|
+
expect(result.repo).toBe("infra-salming-ai");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("parses 'every 2 hours' as schedule", () => {
|
|
134
|
+
const result = parseMessage("run tests every 2 hours on my-app");
|
|
135
|
+
expect(result.type).toBe("schedule");
|
|
136
|
+
expect(result.repo).toBe("my-app");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("parses 'every hour' as schedule", () => {
|
|
140
|
+
const result = parseMessage("check for updates every hour");
|
|
141
|
+
expect(result.type).toBe("schedule");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("parses 'every 15 minutes' as schedule", () => {
|
|
145
|
+
const result = parseMessage("deploy every 15 minutes on staging");
|
|
146
|
+
expect(result.type).toBe("schedule");
|
|
147
|
+
expect(result.repo).toBe("staging");
|
|
148
|
+
});
|
|
149
|
+
|
|
127
150
|
it("parses init repo with SSH URL", () => {
|
|
128
151
|
const result = parseMessage("init repo my-app git@github.com:user/my-app.git");
|
|
129
152
|
expect(result.type).toBe("init-repo");
|
|
@@ -149,6 +172,88 @@ describe("parseMessage", () => {
|
|
|
149
172
|
expect(result.type).not.toBe("init-repo");
|
|
150
173
|
});
|
|
151
174
|
|
|
175
|
+
it("parses Telegram /start as help", () => {
|
|
176
|
+
const result = parseMessage("/start");
|
|
177
|
+
expect(result.type).toBe("help");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("parses Telegram /help as help", () => {
|
|
181
|
+
const result = parseMessage("/help");
|
|
182
|
+
expect(result.type).toBe("help");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("parses Telegram /status as status", () => {
|
|
186
|
+
const result = parseMessage("/status");
|
|
187
|
+
expect(result.type).toBe("status");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("parses Telegram /history as history", () => {
|
|
191
|
+
const result = parseMessage("/history");
|
|
192
|
+
expect(result.type).toBe("history");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("parses Telegram /clear as clear", () => {
|
|
196
|
+
const result = parseMessage("/clear");
|
|
197
|
+
expect(result.type).toBe("clear");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Natural language status inquiries
|
|
201
|
+
it("parses 'how's it going?' as status", () => {
|
|
202
|
+
const result = parseMessage("how's it going?");
|
|
203
|
+
expect(result.type).toBe("status");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("parses 'any updates?' as status", () => {
|
|
207
|
+
const result = parseMessage("any updates?");
|
|
208
|
+
expect(result.type).toBe("status");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("parses 'are you done?' as status", () => {
|
|
212
|
+
const result = parseMessage("are you done?");
|
|
213
|
+
expect(result.type).toBe("status");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("parses 'how dose it the work go?' as status", () => {
|
|
217
|
+
const result = parseMessage("how dose it the work go?");
|
|
218
|
+
expect(result.type).toBe("status");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("parses 'how is the work going' as status", () => {
|
|
222
|
+
const result = parseMessage("how is the work going");
|
|
223
|
+
expect(result.type).toBe("status");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("parses 'what's the progress' as status", () => {
|
|
227
|
+
const result = parseMessage("what's the progress");
|
|
228
|
+
expect(result.type).toBe("status");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("parses 'done yet?' as status", () => {
|
|
232
|
+
const result = parseMessage("done yet?");
|
|
233
|
+
expect(result.type).toBe("status");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("parses 'how far along are you' as status", () => {
|
|
237
|
+
const result = parseMessage("how far along are you");
|
|
238
|
+
expect(result.type).toBe("status");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("parses 'is it done' as status", () => {
|
|
242
|
+
const result = parseMessage("is it done");
|
|
243
|
+
expect(result.type).toBe("status");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("parses 'progress?' as status", () => {
|
|
247
|
+
const result = parseMessage("progress?");
|
|
248
|
+
expect(result.type).toBe("status");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("does NOT parse work requests as status", () => {
|
|
252
|
+
expect(parseMessage("update the progress bar on my-app").type).toBe("free-form");
|
|
253
|
+
expect(parseMessage("fix the status page in my-app").type).toBe("free-form");
|
|
254
|
+
expect(parseMessage("add a progress indicator on my-app").type).toBe("free-form");
|
|
255
|
+
});
|
|
256
|
+
|
|
152
257
|
it("falls back to free-form for unrecognized input", () => {
|
|
153
258
|
const result = parseMessage("what does the auth middleware do in my-app");
|
|
154
259
|
expect(result.type).toBe("free-form");
|
|
@@ -190,3 +295,22 @@ describe("buildPrompt", () => {
|
|
|
190
295
|
expect(prompt).toBe("explain the auth flow");
|
|
191
296
|
});
|
|
192
297
|
});
|
|
298
|
+
|
|
299
|
+
describe("buildCronPrompt", () => {
|
|
300
|
+
it("wraps prompt with autonomy instructions", () => {
|
|
301
|
+
const prompt = buildCronPrompt("simplify code and create a PR");
|
|
302
|
+
expect(prompt).toContain("simplify code and create a PR");
|
|
303
|
+
expect(prompt).toContain("autonomous");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("instructs not to ask questions", () => {
|
|
307
|
+
const prompt = buildCronPrompt("run tests");
|
|
308
|
+
expect(prompt).toMatch(/do not ask|never ask|don't ask/i);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("includes the original prompt verbatim", () => {
|
|
312
|
+
const original = "check for security vulnerabilities and fix them";
|
|
313
|
+
const prompt = buildCronPrompt(original);
|
|
314
|
+
expect(prompt).toContain(original);
|
|
315
|
+
});
|
|
316
|
+
});
|