@adriandmitroca/relay 0.0.2

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.
@@ -0,0 +1,334 @@
1
+ import { logger } from "../utils/logger.ts";
2
+ import { esc, sanitizeTopicName, decodeHtmlEntities } from "../utils/html.ts";
3
+ import { FAILURE_HINTS, type CallbackAction, type CallbackData } from "../constants.ts";
4
+ import type { IssueRow } from "../db.ts";
5
+ import type { TriageResult } from "../worker/triage.ts";
6
+
7
+ export type { CallbackAction, CallbackData } from "../constants.ts";
8
+
9
+ const MAX_MESSAGE_LENGTH = 4096;
10
+
11
+ const SEVERITY_COLORS: Record<string, number> = {
12
+ critical: 0xFB6F5F, // red
13
+ high: 0xFFD67E, // orange
14
+ medium: 0x6FB9F0, // blue
15
+ low: 0x8EEE98, // green
16
+ };
17
+
18
+ const SEVERITY_ICONS: Record<string, string> = {
19
+ critical: "๐Ÿ”ด",
20
+ high: "๐ŸŸ ",
21
+ medium: "๐Ÿ”ต",
22
+ low: "๐ŸŸข",
23
+ };
24
+
25
+ export class TelegramBot {
26
+ private botToken: string;
27
+ private chatId: string;
28
+ private baseUrl: string;
29
+ private offset = 0;
30
+ private polling = false;
31
+ private pollAbort: AbortController | null = null;
32
+ private onCallback: ((data: CallbackData) => Promise<void>) | null = null;
33
+
34
+ constructor(botToken: string, chatId: string) {
35
+ this.botToken = botToken;
36
+ this.chatId = chatId;
37
+ this.baseUrl = `https://api.telegram.org/bot${botToken}`;
38
+ }
39
+
40
+ setCallbackHandler(handler: (data: CallbackData) => Promise<void>) {
41
+ this.onCallback = handler;
42
+ }
43
+
44
+ async createIssueTopic(issue: IssueRow): Promise<number | null> {
45
+ const name = `[${issue.source}] ${sanitizeTopicName(issue.title)}`.slice(0, 128);
46
+ const iconColor = SEVERITY_COLORS[issue.severity] ?? SEVERITY_COLORS.medium;
47
+
48
+ const result = await this.api("createForumTopic", {
49
+ chat_id: this.chatId,
50
+ name,
51
+ icon_color: iconColor,
52
+ });
53
+
54
+ if (!result?.message_thread_id) return null;
55
+ const threadId = result.message_thread_id as number;
56
+
57
+ const sevIcon = SEVERITY_ICONS[issue.severity] ?? "๐Ÿ”ต";
58
+ let text = `<b>${esc(decodeHtmlEntities(issue.title))}</b>\n\n`;
59
+ text += `${sevIcon} <b>${esc(issue.severity)}</b> ยท <code>${esc(issue.source)}:${esc(issue.sourceId)}</code>\n`;
60
+ text += `๐Ÿ“ ${esc(issue.projectKey)} ยท ๐Ÿข ${esc(issue.workspaceKey)}`;
61
+ if (issue.externalUrl) {
62
+ text += `\n\n<a href="${esc(issue.externalUrl)}">View in ${esc(issue.source)} โ†—</a>`;
63
+ }
64
+ await this.sendMessage(truncate(text), undefined, threadId);
65
+
66
+ return threadId;
67
+ }
68
+
69
+ async sendThreadMessage(
70
+ threadId: number,
71
+ text: string,
72
+ keyboard?: Array<Array<{ text: string; callback_data: string }>>,
73
+ ): Promise<number | null> {
74
+ return this.sendMessage(truncate(text), keyboard, threadId);
75
+ }
76
+
77
+ async reopenThread(threadId: number) {
78
+ await this.api("reopenForumTopic", {
79
+ chat_id: this.chatId,
80
+ message_thread_id: threadId,
81
+ });
82
+ }
83
+
84
+ async closeThread(threadId: number, text: string): Promise<number | null> {
85
+ const msgId = await this.sendThreadMessage(threadId, text);
86
+ await this.api("closeForumTopic", {
87
+ chat_id: this.chatId,
88
+ message_thread_id: threadId,
89
+ });
90
+ return msgId;
91
+ }
92
+
93
+ async sendTriageResult(issue: IssueRow, triage: TriageResult, threadId?: number): Promise<number | null> {
94
+ const icon = triage.fixable ? "๐Ÿ”ง" : "โญ";
95
+ const verdict = triage.fixable ? "ACTIONABLE" : "NOT ACTIONABLE";
96
+ const confidence = Math.round(triage.confidence * 100);
97
+
98
+ let text = `${icon} <b>${esc(verdict)}</b> โ€” ${confidence}% confident\n\n`;
99
+ text += `<i>${esc(triage.reason)}</i>`;
100
+
101
+ if (triage.plan) {
102
+ text += `\n\n<b>Plan:</b>\n${esc(triage.plan)}`;
103
+ }
104
+
105
+ text += `\n\n<code>โฑ ${formatDuration(triage.durationMs)} ยท ๐Ÿช™ ${formatTokens(triage.inputTokens, triage.outputTokens)}${triage.costUsd != null ? ` ยท ๐Ÿ’ฐ $${triage.costUsd.toFixed(3)}` : ""}</code>`;
106
+
107
+ const keyboard = triage.fixable
108
+ ? [[
109
+ { text: "โœ… Start", callback_data: `fix:${issue.source}:${issue.sourceId}` },
110
+ { text: "โญ Skip", callback_data: `skip:${issue.source}:${issue.sourceId}` },
111
+ ]]
112
+ : [[
113
+ { text: "๐Ÿ”„ Retry", callback_data: `retry:${issue.source}:${issue.sourceId}` },
114
+ { text: "โญ Skip", callback_data: `skip:${issue.source}:${issue.sourceId}` },
115
+ ]];
116
+
117
+ return this.sendMessage(truncate(text), keyboard, threadId);
118
+ }
119
+
120
+ async sendApproval(
121
+ issue: IssueRow,
122
+ summary: string,
123
+ threadId?: number,
124
+ fixStats?: { durationMs: number; inputTokens: number; outputTokens: number; costUsd?: number },
125
+ ): Promise<number | null> {
126
+ let text = `โœ… <b>WORK COMPLETE</b>\n\n`;
127
+ if (summary) text += `<i>${mdToHtml(summary)}</i>\n\n`;
128
+ if (fixStats) {
129
+ text += `<code>โฑ ${formatDuration(fixStats.durationMs)} ยท ๐Ÿช™ ${formatTokens(fixStats.inputTokens, fixStats.outputTokens)}${fixStats.costUsd != null ? ` ยท ๐Ÿ’ฐ $${fixStats.costUsd.toFixed(3)}` : ""}</code>`;
130
+ }
131
+
132
+ const keyboard = [
133
+ [
134
+ { text: "โœ… Accept", callback_data: `accept:${issue.source}:${issue.sourceId}` },
135
+ { text: "โŒ Discard", callback_data: `discard:${issue.source}:${issue.sourceId}` },
136
+ ],
137
+ ];
138
+
139
+ return this.sendMessage(truncate(text), keyboard, threadId);
140
+ }
141
+
142
+ async sendFixReady(issue: IssueRow, threadId?: number): Promise<number | null> {
143
+ const keyboard = [
144
+ [
145
+ { text: "โœ… Start", callback_data: `fix:${issue.source}:${issue.sourceId}` },
146
+ { text: "โญ Skip", callback_data: `skip:${issue.source}:${issue.sourceId}` },
147
+ ],
148
+ ];
149
+ return this.sendMessage("๐Ÿ”ง <b>READY TO START</b>", keyboard, threadId);
150
+ }
151
+
152
+ async sendFailedMessage(source: string, sourceId: string, error: string, threadId?: number, failureReason?: string): Promise<number | null> {
153
+ let text = `โŒ <b>FAILED</b>`;
154
+ if (failureReason) text += ` ยท <code>${esc(failureReason)}</code>`;
155
+ const hint = failureReason ? FAILURE_HINTS[failureReason] : undefined;
156
+ if (hint) text += `\n\n${esc(hint)}`;
157
+ text += `\n\n<code>${esc(error.slice(0, 300))}</code>`;
158
+ const keyboard = [[{ text: "๐Ÿ”„ Retry", callback_data: `retry:${source}:${sourceId}` }]];
159
+ return this.sendMessage(truncate(text), keyboard, threadId);
160
+ }
161
+
162
+ async sendStatus(text: string): Promise<number | null> {
163
+ return this.sendMessage(truncate(`โ„น๏ธ ${esc(text)}`));
164
+ }
165
+
166
+ async editMessage(messageId: number, text: string) {
167
+ await this.api("editMessageText", {
168
+ chat_id: this.chatId,
169
+ message_id: messageId,
170
+ text: truncate(text),
171
+ parse_mode: "HTML",
172
+ });
173
+ }
174
+
175
+ async editMessageRemoveKeyboard(messageId: number, text: string) {
176
+ await this.api("editMessageText", {
177
+ chat_id: this.chatId,
178
+ message_id: messageId,
179
+ text: truncate(text),
180
+ parse_mode: "HTML",
181
+ reply_markup: JSON.stringify({ inline_keyboard: [] }),
182
+ });
183
+ }
184
+
185
+ startPolling() {
186
+ if (this.polling) return;
187
+ this.polling = true;
188
+ this.pollLoop();
189
+ }
190
+
191
+ stopPolling() {
192
+ this.polling = false;
193
+ this.pollAbort?.abort();
194
+ this.pollAbort = null;
195
+ }
196
+
197
+ private async pollLoop() {
198
+ while (this.polling) {
199
+ try {
200
+ this.pollAbort = new AbortController();
201
+ const timeoutId = setTimeout(() => this.pollAbort?.abort(), 35_000);
202
+ const resp = await fetch(`${this.baseUrl}/getUpdates?offset=${this.offset}&timeout=30&allowed_updates=["callback_query"]`, {
203
+ signal: this.pollAbort.signal,
204
+ });
205
+ clearTimeout(timeoutId);
206
+
207
+ if (!resp.ok) {
208
+ logger.error("Telegram poll error", { status: resp.status });
209
+ await Bun.sleep(5_000);
210
+ continue;
211
+ }
212
+
213
+ const body = (await resp.json()) as { ok: boolean; result: TelegramUpdate[] };
214
+ if (!body.ok || !body.result.length) continue;
215
+
216
+ for (const update of body.result) {
217
+ this.offset = update.update_id + 1;
218
+
219
+ if (update.callback_query) {
220
+ await this.handleCallback(update.callback_query);
221
+ }
222
+ }
223
+ } catch (err) {
224
+ if (this.polling) {
225
+ logger.warn("Telegram poll error, retrying in 5s", { error: String(err) });
226
+ await Bun.sleep(5_000);
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ private async handleCallback(query: TelegramCallbackQuery) {
233
+ // Acknowledge immediately
234
+ await this.api("answerCallbackQuery", { callback_query_id: query.id });
235
+
236
+ if (!query.data || !this.onCallback) return;
237
+
238
+ const parts = query.data.split(":");
239
+ if (parts.length < 3) return;
240
+
241
+ const [action, source, ...rest] = parts;
242
+ const sourceId = rest.join(":");
243
+
244
+ if (!["accept", "discard", "skip", "fix", "retry"].includes(action)) return;
245
+
246
+ try {
247
+ await this.onCallback({
248
+ action: action as CallbackAction,
249
+ source,
250
+ sourceId,
251
+ });
252
+ } catch (err) {
253
+ logger.error("Callback handler error", { error: String(err) });
254
+ }
255
+ }
256
+
257
+ private async sendMessage(
258
+ text: string,
259
+ keyboard?: Array<Array<{ text: string; callback_data: string }>>,
260
+ messageThreadId?: number,
261
+ ): Promise<number | null> {
262
+ const body: Record<string, unknown> = {
263
+ chat_id: this.chatId,
264
+ text,
265
+ parse_mode: "HTML",
266
+ disable_web_page_preview: true,
267
+ };
268
+
269
+ if (keyboard) {
270
+ body.reply_markup = JSON.stringify({ inline_keyboard: keyboard });
271
+ }
272
+
273
+ if (messageThreadId) {
274
+ body.message_thread_id = messageThreadId;
275
+ }
276
+
277
+ const result = await this.api("sendMessage", body);
278
+ return result?.message_id ?? null;
279
+ }
280
+
281
+ private async api(method: string, body: Record<string, unknown>): Promise<Record<string, unknown> | null> {
282
+ try {
283
+ const resp = await fetch(`${this.baseUrl}/${method}`, {
284
+ method: "POST",
285
+ headers: { "Content-Type": "application/json" },
286
+ body: JSON.stringify(body),
287
+ });
288
+
289
+ if (!resp.ok) {
290
+ const text = await resp.text();
291
+ logger.error("Telegram API error", { method, status: resp.status, body: text.slice(0, 200) });
292
+ return null;
293
+ }
294
+
295
+ const data = (await resp.json()) as { ok: boolean; result?: Record<string, unknown> };
296
+ return data.result ?? null;
297
+ } catch (err) {
298
+ logger.error("Telegram API call failed", { method, error: String(err) });
299
+ return null;
300
+ }
301
+ }
302
+ }
303
+
304
+ function formatDuration(ms: number): string {
305
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
306
+ return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
307
+ }
308
+
309
+ function formatTokens(input: number, output: number): string {
310
+ const fmt = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n);
311
+ return `${fmt(input)} in / ${fmt(output)} out`;
312
+ }
313
+
314
+ function mdToHtml(md: string): string {
315
+ return esc(md)
316
+ .replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
317
+ .replace(/`([^`]+)`/g, "<code>$1</code>");
318
+ }
319
+
320
+ function truncate(s: string): string {
321
+ if (s.length <= MAX_MESSAGE_LENGTH) return s;
322
+ return s.slice(0, MAX_MESSAGE_LENGTH - 20) + "\n\n(truncated)";
323
+ }
324
+
325
+ interface TelegramUpdate {
326
+ update_id: number;
327
+ callback_query?: TelegramCallbackQuery;
328
+ }
329
+
330
+ interface TelegramCallbackQuery {
331
+ id: string;
332
+ data?: string;
333
+ message?: { message_id: number; chat: { id: number } };
334
+ }
package/src/queue.ts ADDED
@@ -0,0 +1,98 @@
1
+ import type { Severity } from "./sources/types.ts";
2
+ import { logger } from "./utils/logger.ts";
3
+
4
+ const PRIORITY: Record<Severity, number> = {
5
+ critical: 0,
6
+ high: 1,
7
+ medium: 2,
8
+ low: 3,
9
+ };
10
+
11
+ interface QueueItem<T> {
12
+ id: string;
13
+ data: T;
14
+ priority: number;
15
+ }
16
+
17
+ export class PriorityQueue<T> {
18
+ private items: QueueItem<T>[] = [];
19
+ private active = new Set<string>();
20
+ private maxConcurrency: number;
21
+ private running = 0;
22
+ private paused = false;
23
+ private processor: (data: T) => Promise<void>;
24
+ private drainResolve: (() => void) | null = null;
25
+
26
+ constructor(maxConcurrency: number, processor: (data: T) => Promise<void>) {
27
+ this.maxConcurrency = maxConcurrency;
28
+ this.processor = processor;
29
+ }
30
+
31
+ enqueue(id: string, data: T, severity: Severity): boolean {
32
+ if (this.active.has(id) || this.items.some((i) => i.id === id)) {
33
+ return false; // dedup
34
+ }
35
+
36
+ const priority = PRIORITY[severity] ?? 2;
37
+ this.items.push({ id, data, priority });
38
+ this.items.sort((a, b) => a.priority - b.priority);
39
+ this.tryProcess();
40
+ return true;
41
+ }
42
+
43
+ pause() {
44
+ this.paused = true;
45
+ }
46
+
47
+ resume() {
48
+ this.paused = false;
49
+ this.tryProcess();
50
+ }
51
+
52
+ async drain(timeoutMs = 300_000): Promise<void> {
53
+ this.paused = true; // stop accepting new work
54
+ this.items = []; // clear pending
55
+
56
+ if (this.running === 0) return;
57
+
58
+ return new Promise<void>((resolve) => {
59
+ let resolved = false;
60
+ const done = () => {
61
+ if (resolved) return;
62
+ resolved = true;
63
+ this.drainResolve = null;
64
+ resolve();
65
+ };
66
+ this.drainResolve = done;
67
+ setTimeout(done, timeoutMs);
68
+ });
69
+ }
70
+
71
+ get size(): number {
72
+ return this.items.length;
73
+ }
74
+
75
+ get activeCount(): number {
76
+ return this.running;
77
+ }
78
+
79
+ private tryProcess() {
80
+ while (!this.paused && this.running < this.maxConcurrency && this.items.length > 0) {
81
+ const item = this.items.shift()!;
82
+ this.active.add(item.id);
83
+ this.running++;
84
+
85
+ this.processor(item.data)
86
+ .catch((err) => logger.error("Queue processor error", { id: item.id, error: String(err) }))
87
+ .finally(() => {
88
+ this.active.delete(item.id);
89
+ this.running--;
90
+ if (this.running === 0 && this.drainResolve) {
91
+ this.drainResolve();
92
+ this.drainResolve = null;
93
+ }
94
+ this.tryProcess();
95
+ });
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,161 @@
1
+ import type { NormalizedIssue, Severity, SourceAdapter } from "./types.ts";
2
+ import type { AsanaSourceConfig } from "../config.ts";
3
+ import { logger } from "../utils/logger.ts";
4
+
5
+ export class AsanaAdapter implements SourceAdapter {
6
+ readonly name = "asana";
7
+ private accessToken: string;
8
+ private configs: Map<string, AsanaSourceConfig> = new Map();
9
+
10
+ constructor(accessToken: string) {
11
+ this.accessToken = accessToken;
12
+ }
13
+
14
+ addProject(projectKey: string, config: AsanaSourceConfig) {
15
+ this.configs.set(projectKey, config);
16
+ }
17
+
18
+ async poll(projectKey: string): Promise<NormalizedIssue[]> {
19
+ const config = this.configs.get(projectKey);
20
+ if (!config) return [];
21
+
22
+ const fields = "gid,name,notes,completed,created_at,permalink_url,tags,custom_fields";
23
+ const url = `https://app.asana.com/api/1.0/projects/${config.projectGid}/tasks?completed_since=now&opt_fields=${fields}&limit=50`;
24
+
25
+ const tasks = await this.fetchPaginated(url, 3);
26
+ return tasks
27
+ .filter((t) => !t.completed)
28
+ .map((t) => this.normalize(t, projectKey, config));
29
+ }
30
+
31
+ async getIssueContext(issue: NormalizedIssue): Promise<string> {
32
+ const parts: string[] = [`# ${issue.title}\n`, issue.body];
33
+
34
+ const storiesUrl = `https://app.asana.com/api/1.0/tasks/${issue.sourceId}/stories?opt_fields=text,type,created_by.name,created_at`;
35
+ const resp = await this.request(storiesUrl);
36
+ if (resp.ok) {
37
+ const data = (await resp.json()) as { data: AsanaStory[] };
38
+ const comments = data.data.filter((s) => s.type === "comment" && s.text);
39
+ if (comments.length) {
40
+ parts.push("\n## Comments");
41
+ for (const c of comments.slice(-10)) {
42
+ parts.push(`\n**${c.created_by?.name ?? "Unknown"}** (${c.created_at}):\n${c.text}`);
43
+ }
44
+ }
45
+ }
46
+
47
+ return parts.join("\n");
48
+ }
49
+
50
+ async onFixAccepted(issue: NormalizedIssue, prUrl: string): Promise<void> {
51
+ const url = `https://app.asana.com/api/1.0/tasks/${issue.sourceId}/stories`;
52
+ const resp = await this.request(url, {
53
+ method: "POST",
54
+ body: JSON.stringify({ data: { text: `Relay fix applied: ${prUrl}` } }),
55
+ });
56
+ if (!resp.ok) {
57
+ logger.warn("Failed to comment on Asana task", { taskGid: issue.sourceId });
58
+ }
59
+ }
60
+
61
+ private normalize(raw: AsanaTask, projectKey: string, config: AsanaSourceConfig): NormalizedIssue {
62
+ return {
63
+ source: "asana",
64
+ sourceId: raw.gid,
65
+ externalUrl: raw.permalink_url ?? `https://app.asana.com/0/0/${raw.gid}`,
66
+ projectKey,
67
+ title: raw.name,
68
+ body: raw.notes ?? "",
69
+ severity: this.extractSeverity(raw, config),
70
+ metadata: {
71
+ createdAt: raw.created_at,
72
+ tags: raw.tags?.map((t) => t.name) ?? [],
73
+ },
74
+ };
75
+ }
76
+
77
+ private extractSeverity(task: AsanaTask, config: AsanaSourceConfig): Severity {
78
+ if (config.severityFieldGid && task.custom_fields) {
79
+ const field = task.custom_fields.find((f) => f.gid === config.severityFieldGid);
80
+ if (field?.enum_value?.name) {
81
+ const name = field.enum_value.name.toLowerCase();
82
+ if (name.includes("critical")) return "critical";
83
+ if (name.includes("high")) return "high";
84
+ if (name.includes("low")) return "low";
85
+ }
86
+ }
87
+
88
+ if (task.tags) {
89
+ for (const tag of task.tags) {
90
+ const n = tag.name.toLowerCase();
91
+ if (n.includes("critical") || n.includes("p0")) return "critical";
92
+ if (n.includes("high") || n.includes("p1")) return "high";
93
+ if (n.includes("low") || n.includes("p3")) return "low";
94
+ }
95
+ }
96
+
97
+ return "medium";
98
+ }
99
+
100
+ private async fetchPaginated(url: string, maxPages: number): Promise<AsanaTask[]> {
101
+ const all: AsanaTask[] = [];
102
+ let nextUrl: string | null = url;
103
+ let page = 0;
104
+
105
+ while (nextUrl && page < maxPages) {
106
+ const resp = await this.request(nextUrl);
107
+ if (!resp.ok) {
108
+ logger.error("Asana API error", { status: resp.status });
109
+ break;
110
+ }
111
+ const body = (await resp.json()) as { data: AsanaTask[]; next_page?: { uri: string } | null };
112
+ all.push(...body.data);
113
+ nextUrl = body.next_page?.uri ?? null;
114
+ page++;
115
+ }
116
+
117
+ return all;
118
+ }
119
+
120
+ private async request(url: string, init?: RequestInit, retries = 0): Promise<Response> {
121
+ const resp = await fetch(url, {
122
+ ...init,
123
+ headers: {
124
+ Authorization: `Bearer ${this.accessToken}`,
125
+ "Content-Type": "application/json",
126
+ ...init?.headers,
127
+ },
128
+ });
129
+
130
+ if (resp.status === 429 && retries < 3) {
131
+ const retryAfter = resp.headers.get("retry-after");
132
+ const wait = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60_000;
133
+ logger.warn("Asana rate limited", { retryAfterMs: wait, attempt: retries + 1 });
134
+ await Bun.sleep(wait);
135
+ return this.request(url, init, retries + 1);
136
+ }
137
+
138
+ return resp;
139
+ }
140
+ }
141
+
142
+ interface AsanaTask {
143
+ gid: string;
144
+ name: string;
145
+ notes?: string;
146
+ completed: boolean;
147
+ created_at: string;
148
+ permalink_url?: string;
149
+ tags?: Array<{ gid: string; name: string }>;
150
+ custom_fields?: Array<{
151
+ gid: string;
152
+ enum_value?: { name: string } | null;
153
+ }>;
154
+ }
155
+
156
+ interface AsanaStory {
157
+ type: string;
158
+ text?: string;
159
+ created_by?: { name: string };
160
+ created_at: string;
161
+ }