@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovenyberg/ove",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Your grumpy but meticulous dev companion. AI coding agent for Slack, WhatsApp, Telegram, Discord, GitHub, HTTP API, and CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@slack/bolt": "^4.1.0",
|
|
35
|
-
"baileys": "^
|
|
35
|
+
"baileys": "^7.0.0-rc.9",
|
|
36
36
|
"discord.js": "^14.25.1",
|
|
37
37
|
"grammy": "^1.40.0",
|
|
38
38
|
"qrcode-terminal": "^0.12.0"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
|
|
2
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
3
|
+
return ((...args: any[]) => {
|
|
4
|
+
if (timer) clearTimeout(timer);
|
|
5
|
+
timer = setTimeout(() => {
|
|
6
|
+
timer = null;
|
|
7
|
+
fn(...args);
|
|
8
|
+
}, ms);
|
|
9
|
+
}) as any as T;
|
|
10
|
+
}
|
package/src/adapters/discord.ts
CHANGED
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
import { Client, GatewayIntentBits, type Message } from "discord.js";
|
|
2
2
|
import type { ChatAdapter, IncomingMessage } from "./types";
|
|
3
3
|
import { logger } from "../logger";
|
|
4
|
-
|
|
5
|
-
function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
|
|
6
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
7
|
-
return ((...args: any[]) => {
|
|
8
|
-
if (timer) clearTimeout(timer);
|
|
9
|
-
timer = setTimeout(() => {
|
|
10
|
-
timer = null;
|
|
11
|
-
fn(...args);
|
|
12
|
-
}, ms);
|
|
13
|
-
}) as any as T;
|
|
14
|
-
}
|
|
4
|
+
import { debounce } from "./debounce";
|
|
15
5
|
|
|
16
6
|
export class DiscordAdapter implements ChatAdapter {
|
|
17
7
|
private client: Client;
|
package/src/adapters/github.ts
CHANGED
|
@@ -115,6 +115,18 @@ export class GitHubAdapter implements EventAdapter {
|
|
|
115
115
|
logger.info("github mention detected", { repo, user: comment.user.login, number });
|
|
116
116
|
this.onEvent?.(event);
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
this.pruneSeenIds();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private pruneSeenIds() {
|
|
123
|
+
if (this.seenCommentIds.size > 1000) {
|
|
124
|
+
const iter = this.seenCommentIds.values();
|
|
125
|
+
while (this.seenCommentIds.size > 800) {
|
|
126
|
+
const val = iter.next().value;
|
|
127
|
+
if (val !== undefined) this.seenCommentIds.delete(val);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
private async fetchRecentComments(repo: string): Promise<GitHubComment[]> {
|
package/src/adapters/http.ts
CHANGED
|
@@ -94,7 +94,8 @@ export class HttpApiAdapter implements EventAdapter {
|
|
|
94
94
|
controller.enqueue(`data: ${data}\n\n`);
|
|
95
95
|
},
|
|
96
96
|
cancel() {
|
|
97
|
-
|
|
97
|
+
const idx = pending.sseControllers.indexOf(controller);
|
|
98
|
+
if (idx >= 0) pending.sseControllers.splice(idx, 1);
|
|
98
99
|
},
|
|
99
100
|
});
|
|
100
101
|
|
|
@@ -146,9 +147,14 @@ export class HttpApiAdapter implements EventAdapter {
|
|
|
146
147
|
try {
|
|
147
148
|
controller.enqueue(`data: ${data}\n\n`);
|
|
148
149
|
controller.close();
|
|
149
|
-
} catch {
|
|
150
|
+
} catch (err) {
|
|
151
|
+
logger.debug("sse enqueue failed", { eventId, error: String(err) });
|
|
152
|
+
}
|
|
150
153
|
}
|
|
151
154
|
pending.sseControllers = [];
|
|
155
|
+
|
|
156
|
+
// Clean up event after 5 minutes
|
|
157
|
+
setTimeout(() => this.events.delete(eventId), 5 * 60 * 1000);
|
|
152
158
|
}
|
|
153
159
|
|
|
154
160
|
/** Called by index.ts to push status updates to SSE clients */
|
|
@@ -161,7 +167,9 @@ export class HttpApiAdapter implements EventAdapter {
|
|
|
161
167
|
for (const controller of pending.sseControllers) {
|
|
162
168
|
try {
|
|
163
169
|
controller.enqueue(`data: ${data}\n\n`);
|
|
164
|
-
} catch {
|
|
170
|
+
} catch (err) {
|
|
171
|
+
logger.debug("sse status update failed", { eventId, error: String(err) });
|
|
172
|
+
}
|
|
165
173
|
}
|
|
166
174
|
}
|
|
167
175
|
}
|
package/src/adapters/slack.ts
CHANGED
|
@@ -2,17 +2,7 @@
|
|
|
2
2
|
import { App } from "@slack/bolt";
|
|
3
3
|
import type { ChatAdapter, IncomingMessage } from "./types";
|
|
4
4
|
import { logger } from "../logger";
|
|
5
|
-
|
|
6
|
-
function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
|
|
7
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
8
|
-
return ((...args: any[]) => {
|
|
9
|
-
if (timer) clearTimeout(timer);
|
|
10
|
-
timer = setTimeout(() => {
|
|
11
|
-
timer = null;
|
|
12
|
-
fn(...args);
|
|
13
|
-
}, ms);
|
|
14
|
-
}) as any as T;
|
|
15
|
-
}
|
|
5
|
+
import { debounce } from "./debounce";
|
|
16
6
|
|
|
17
7
|
export class SlackAdapter implements ChatAdapter {
|
|
18
8
|
private app: App;
|
package/src/adapters/telegram.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { Bot } from "grammy";
|
|
2
2
|
import type { ChatAdapter, IncomingMessage } from "./types";
|
|
3
3
|
import { logger } from "../logger";
|
|
4
|
+
import { debounce } from "./debounce";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
/** Convert simple markdown (*bold*, `code`, ```blocks```) to Telegram HTML */
|
|
7
|
+
function mdToHtml(text: string): string {
|
|
8
|
+
// Escape HTML entities first
|
|
9
|
+
let html = text
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>');
|
|
13
|
+
|
|
14
|
+
return html
|
|
15
|
+
// Code blocks first (```lang\n...\n```)
|
|
16
|
+
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre>$2</pre>')
|
|
17
|
+
// Inline code
|
|
18
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
19
|
+
// Bold **text** or *text*
|
|
20
|
+
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
|
21
|
+
.replace(/\*(.+?)\*/g, '<b>$1</b>');
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
export class TelegramAdapter implements ChatAdapter {
|
|
@@ -30,18 +38,21 @@ export class TelegramAdapter implements ChatAdapter {
|
|
|
30
38
|
const userId = `telegram:${ctx.from.id}`;
|
|
31
39
|
const text = ctx.message.text;
|
|
32
40
|
let statusMsgId: number | undefined;
|
|
41
|
+
let lastStatusText: string | undefined;
|
|
33
42
|
|
|
34
43
|
const doUpdate = debounce(async (statusText: string) => {
|
|
44
|
+
if (statusText === lastStatusText) return; // skip unchanged text
|
|
45
|
+
lastStatusText = statusText;
|
|
46
|
+
const html = mdToHtml(statusText);
|
|
35
47
|
try {
|
|
36
48
|
if (statusMsgId) {
|
|
37
|
-
await ctx.api.editMessageText(chatId, statusMsgId,
|
|
49
|
+
await ctx.api.editMessageText(chatId, statusMsgId, html, { parse_mode: "HTML" });
|
|
38
50
|
} else {
|
|
39
|
-
const sent = await ctx.reply(
|
|
51
|
+
const sent = await ctx.reply(html, { parse_mode: "HTML" });
|
|
40
52
|
statusMsgId = sent.message_id;
|
|
41
53
|
}
|
|
42
54
|
} catch {
|
|
43
|
-
|
|
44
|
-
statusMsgId = sent.message_id;
|
|
55
|
+
// Edit may fail if content unchanged or message too old — ignore
|
|
45
56
|
}
|
|
46
57
|
}, 3000);
|
|
47
58
|
|
|
@@ -50,7 +61,18 @@ export class TelegramAdapter implements ChatAdapter {
|
|
|
50
61
|
platform: "telegram",
|
|
51
62
|
text,
|
|
52
63
|
reply: async (replyText: string) => {
|
|
53
|
-
|
|
64
|
+
// Replace the status message with the first reply, then send new messages for the rest
|
|
65
|
+
if (statusMsgId) {
|
|
66
|
+
try {
|
|
67
|
+
await ctx.api.editMessageText(chatId, statusMsgId, mdToHtml(replyText), { parse_mode: "HTML" });
|
|
68
|
+
statusMsgId = undefined;
|
|
69
|
+
return;
|
|
70
|
+
} catch {
|
|
71
|
+
// Edit failed (message too old, etc.) — fall through to send new
|
|
72
|
+
}
|
|
73
|
+
statusMsgId = undefined;
|
|
74
|
+
}
|
|
75
|
+
await ctx.reply(mdToHtml(replyText), { parse_mode: "HTML" });
|
|
54
76
|
},
|
|
55
77
|
updateStatus: doUpdate,
|
|
56
78
|
};
|
|
@@ -70,6 +92,6 @@ export class TelegramAdapter implements ChatAdapter {
|
|
|
70
92
|
|
|
71
93
|
async sendToUser(userId: string, text: string): Promise<void> {
|
|
72
94
|
const chatId = userId.replace("telegram:", "");
|
|
73
|
-
await this.bot.api.sendMessage(Number(chatId), text);
|
|
95
|
+
await this.bot.api.sendMessage(Number(chatId), mdToHtml(text), { parse_mode: "HTML" });
|
|
74
96
|
}
|
|
75
97
|
}
|
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -11,9 +11,13 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
11
11
|
private sock: WASocket | null = null;
|
|
12
12
|
private onMessage?: (msg: IncomingMessage) => void;
|
|
13
13
|
private authDir: string;
|
|
14
|
+
private phoneNumber?: string;
|
|
15
|
+
private reconnectAttempt = 0;
|
|
16
|
+
private sentByBot = new Set<string>();
|
|
14
17
|
|
|
15
|
-
constructor(
|
|
16
|
-
this.authDir = authDir;
|
|
18
|
+
constructor(opts: { authDir?: string; phoneNumber?: string } = {}) {
|
|
19
|
+
this.authDir = opts.authDir ?? "./auth/whatsapp";
|
|
20
|
+
this.phoneNumber = opts.phoneNumber;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
async start(onMessage: (msg: IncomingMessage) => void): Promise<void> {
|
|
@@ -23,28 +27,56 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
23
27
|
|
|
24
28
|
this.sock = makeWASocket({
|
|
25
29
|
auth: state,
|
|
26
|
-
printQRInTerminal: true,
|
|
27
30
|
});
|
|
28
31
|
|
|
29
32
|
this.sock.ev.on("creds.update", saveCreds);
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
let pairingRequested = false;
|
|
35
|
+
|
|
36
|
+
this.sock.ev.on("connection.update", async ({ connection, lastDisconnect, qr }) => {
|
|
37
|
+
// Request pairing code when server is ready (sends qr event)
|
|
38
|
+
if (qr && this.phoneNumber && !pairingRequested) {
|
|
39
|
+
pairingRequested = true;
|
|
40
|
+
try {
|
|
41
|
+
const phone = this.phoneNumber.replace(/[^0-9]/g, "");
|
|
42
|
+
const code = await this.sock!.requestPairingCode(phone);
|
|
43
|
+
logger.info(`whatsapp pairing code: ${code}`, { phone });
|
|
44
|
+
console.log(`\n WhatsApp pairing code: ${code}`);
|
|
45
|
+
console.log(` Enter this code on your phone: WhatsApp → Linked Devices → Link a Device\n`);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
logger.error("failed to request pairing code", { error: String(err) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
if (connection === "close") {
|
|
33
52
|
const statusCode = (lastDisconnect?.error as any)?.output?.statusCode;
|
|
34
53
|
if (statusCode !== DisconnectReason.loggedOut) {
|
|
35
|
-
|
|
36
|
-
this.
|
|
54
|
+
this.reconnectAttempt++;
|
|
55
|
+
const delay = Math.min(2000 * this.reconnectAttempt, 30_000);
|
|
56
|
+
logger.warn("whatsapp disconnected, reconnecting...", { statusCode, delay });
|
|
57
|
+
setTimeout(() => this.start(onMessage), delay);
|
|
37
58
|
} else {
|
|
38
59
|
logger.error("whatsapp logged out");
|
|
39
60
|
}
|
|
40
61
|
} else if (connection === "open") {
|
|
62
|
+
this.reconnectAttempt = 0;
|
|
41
63
|
logger.info("whatsapp adapter connected");
|
|
42
64
|
}
|
|
43
65
|
});
|
|
44
66
|
|
|
45
67
|
this.sock.ev.on("messages.upsert", ({ messages }) => {
|
|
46
68
|
for (const waMsg of messages) {
|
|
47
|
-
if (!waMsg.message
|
|
69
|
+
if (!waMsg.message) continue;
|
|
70
|
+
|
|
71
|
+
// Skip messages sent by the bot (replies/status updates)
|
|
72
|
+
const msgId = waMsg.key.id;
|
|
73
|
+
if (msgId && this.sentByBot.has(msgId)) {
|
|
74
|
+
this.sentByBot.delete(msgId);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Skip messages from others (not our phone) — we only process our own commands
|
|
79
|
+
if (!waMsg.key.fromMe) continue;
|
|
48
80
|
|
|
49
81
|
const text =
|
|
50
82
|
waMsg.message.conversation ||
|
|
@@ -54,7 +86,10 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
54
86
|
const jid = waMsg.key.remoteJid;
|
|
55
87
|
if (!jid) continue;
|
|
56
88
|
|
|
57
|
-
|
|
89
|
+
// For fromMe messages, use our configured phone number
|
|
90
|
+
const phone = waMsg.key.fromMe
|
|
91
|
+
? (this.phoneNumber?.replace(/[^0-9]/g, "") ?? jid.split("@")[0])
|
|
92
|
+
: jid.split("@")[0];
|
|
58
93
|
const userId = `whatsapp:${phone}`;
|
|
59
94
|
|
|
60
95
|
// Batch status updates: at most once per 10 seconds
|
|
@@ -64,7 +99,8 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
64
99
|
|
|
65
100
|
const flushStatus = async () => {
|
|
66
101
|
if (pendingStatus && this.sock) {
|
|
67
|
-
await this.sock.sendMessage(jid, { text: pendingStatus });
|
|
102
|
+
const sent = await this.sock.sendMessage(jid, { text: pendingStatus });
|
|
103
|
+
if (sent?.key?.id) this.sentByBot.add(sent.key.id);
|
|
68
104
|
lastSentAt = Date.now();
|
|
69
105
|
pendingStatus = null;
|
|
70
106
|
}
|
|
@@ -82,7 +118,8 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
82
118
|
batchTimer = null;
|
|
83
119
|
pendingStatus = null;
|
|
84
120
|
}
|
|
85
|
-
await this.sock?.sendMessage(jid, { text: replyText });
|
|
121
|
+
const sent = await this.sock?.sendMessage(jid, { text: replyText });
|
|
122
|
+
if (sent?.key?.id) this.sentByBot.add(sent.key.id);
|
|
86
123
|
},
|
|
87
124
|
updateStatus: async (statusText: string) => {
|
|
88
125
|
pendingStatus = statusText;
|
|
@@ -113,6 +150,7 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
113
150
|
async sendToUser(userId: string, text: string): Promise<void> {
|
|
114
151
|
const phone = userId.replace("whatsapp:", "");
|
|
115
152
|
const jid = `${phone}@s.whatsapp.net`;
|
|
116
|
-
await this.sock?.sendMessage(jid, { text });
|
|
153
|
+
const sent = await this.sock?.sendMessage(jid, { text });
|
|
154
|
+
if (sent?.key?.id) this.sentByBot.add(sent.key.id);
|
|
117
155
|
}
|
|
118
156
|
}
|
package/src/config.test.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
2
|
import { loadConfig, isAuthorized, getUserRepos, saveConfig, addRepo, addUser } from "./config";
|
|
3
|
+
import type { Config } from "./config";
|
|
3
4
|
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
4
5
|
|
|
6
|
+
function makeConfig(overrides: Partial<Config> = {}): Config {
|
|
7
|
+
return {
|
|
8
|
+
repos: {},
|
|
9
|
+
users: {},
|
|
10
|
+
claude: { maxTurns: 25 },
|
|
11
|
+
reposDir: "./repos",
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
describe("loadConfig", () => {
|
|
6
17
|
it("returns config with repos and users", () => {
|
|
7
18
|
const config = loadConfig();
|
|
@@ -99,3 +110,62 @@ describe("saveConfig / addRepo / addUser", () => {
|
|
|
99
110
|
expect(config.users["slack:U1"].repos).toEqual(["a", "b", "c"]);
|
|
100
111
|
});
|
|
101
112
|
});
|
|
113
|
+
|
|
114
|
+
describe("wildcard auth", () => {
|
|
115
|
+
it("grants access to any repo with wildcard", () => {
|
|
116
|
+
const config = makeConfig({
|
|
117
|
+
users: { "tg:123": { name: "love", repos: ["*"] } },
|
|
118
|
+
});
|
|
119
|
+
expect(isAuthorized(config, "tg:123", "any-repo")).toBe(true);
|
|
120
|
+
expect(isAuthorized(config, "tg:123", "another-repo")).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("still works with explicit repo list", () => {
|
|
124
|
+
const config = makeConfig({
|
|
125
|
+
users: { "tg:123": { name: "love", repos: ["my-app"] } },
|
|
126
|
+
});
|
|
127
|
+
expect(isAuthorized(config, "tg:123", "my-app")).toBe(true);
|
|
128
|
+
expect(isAuthorized(config, "tg:123", "other")).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("denies unknown user", () => {
|
|
132
|
+
const config = makeConfig();
|
|
133
|
+
expect(isAuthorized(config, "unknown", "repo")).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("allows user without repo check", () => {
|
|
137
|
+
const config = makeConfig({
|
|
138
|
+
users: { "tg:123": { name: "love", repos: [] } },
|
|
139
|
+
});
|
|
140
|
+
expect(isAuthorized(config, "tg:123")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("getUserRepos with wildcard", () => {
|
|
145
|
+
it("returns ['*'] when user has wildcard", () => {
|
|
146
|
+
const config = makeConfig({
|
|
147
|
+
users: { "tg:123": { name: "love", repos: ["*"] } },
|
|
148
|
+
});
|
|
149
|
+
expect(getUserRepos(config, "tg:123")).toEqual(["*"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns empty for unknown user", () => {
|
|
153
|
+
const config = makeConfig();
|
|
154
|
+
expect(getUserRepos(config, "unknown")).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("github config type", () => {
|
|
159
|
+
it("accepts github config on Config", () => {
|
|
160
|
+
const config = makeConfig({
|
|
161
|
+
github: { syncInterval: 60000, orgs: ["my-org"] },
|
|
162
|
+
});
|
|
163
|
+
expect(config.github!.syncInterval).toBe(60000);
|
|
164
|
+
expect(config.github!.orgs).toEqual(["my-org"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("github config is optional", () => {
|
|
168
|
+
const config = makeConfig();
|
|
169
|
+
expect(config.github).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -6,9 +6,10 @@ export interface RunnerConfig {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export interface RepoConfig {
|
|
9
|
-
url
|
|
10
|
-
defaultBranch
|
|
9
|
+
url?: string;
|
|
10
|
+
defaultBranch?: string;
|
|
11
11
|
runner?: RunnerConfig;
|
|
12
|
+
excluded?: boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export interface UserConfig {
|
|
@@ -30,6 +31,11 @@ export interface CronTaskConfig {
|
|
|
30
31
|
userId: string;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
export interface GitHubConfig {
|
|
35
|
+
syncInterval?: number;
|
|
36
|
+
orgs?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
export interface Config {
|
|
34
40
|
repos: Record<string, RepoConfig>;
|
|
35
41
|
users: Record<string, UserConfig>;
|
|
@@ -40,6 +46,7 @@ export interface Config {
|
|
|
40
46
|
mcpServers?: Record<string, McpServerConfig>;
|
|
41
47
|
cron?: CronTaskConfig[];
|
|
42
48
|
runner?: RunnerConfig;
|
|
49
|
+
github?: GitHubConfig;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
export function loadConfig(): Config {
|
|
@@ -54,8 +61,10 @@ export function loadConfig(): Config {
|
|
|
54
61
|
mcpServers: raw.mcpServers,
|
|
55
62
|
cron: raw.cron,
|
|
56
63
|
runner: raw.runner,
|
|
64
|
+
github: raw.github,
|
|
57
65
|
};
|
|
58
66
|
} catch {
|
|
67
|
+
// Config file doesn't exist yet or is invalid — use defaults
|
|
59
68
|
return {
|
|
60
69
|
repos: {},
|
|
61
70
|
users: {},
|
|
@@ -77,7 +86,7 @@ export function isAuthorized(config: Config, platformUserId: string, repo?: stri
|
|
|
77
86
|
const user = config.users[platformUserId];
|
|
78
87
|
if (!user) return false;
|
|
79
88
|
if (!repo) return true;
|
|
80
|
-
return user.repos.includes(repo);
|
|
89
|
+
return user.repos.includes("*") || user.repos.includes(repo);
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
export function saveConfig(config: Config): void {
|
|
@@ -86,11 +95,14 @@ export function saveConfig(config: Config): void {
|
|
|
86
95
|
let existing: Record<string, any> = {};
|
|
87
96
|
try {
|
|
88
97
|
existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
89
|
-
} catch {
|
|
98
|
+
} catch {
|
|
99
|
+
// File doesn't exist yet or is invalid — start with empty object
|
|
100
|
+
}
|
|
90
101
|
const merged = { ...existing, repos: config.repos, users: config.users, claude: config.claude, reposDir: config.reposDir };
|
|
91
102
|
if (config.mcpServers) merged.mcpServers = config.mcpServers;
|
|
92
103
|
if (config.cron) merged.cron = config.cron;
|
|
93
104
|
if (config.runner) merged.runner = config.runner;
|
|
105
|
+
if (config.github) merged.github = config.github;
|
|
94
106
|
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
|
|
95
107
|
}
|
|
96
108
|
|