diff --git a/src/ai/__tests__/assessPRFeedback.test.ts b/src/ai/__tests__/assessPRFeedback.test.ts new file mode 100644 index 0000000..a03a420 --- /dev/null +++ b/src/ai/__tests__/assessPRFeedback.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, + metadata: { set: vi.fn(), append: vi.fn() }, +})); + +const mockRunClaudeCodeAgent = vi.fn(); +vi.mock("../../sandboxes/runClaudeCodeAgent", () => ({ + runClaudeCodeAgent: (...args: unknown[]) => mockRunClaudeCodeAgent(...args), +})); + +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), +})); + +const { assessPRFeedback } = await import("../assessPRFeedback"); + +const mockSandbox = {} as any; + +const noFeedback = { reviews: [], comments: [] }; +const withFeedback = { + reviews: [ + { + author: "reviewer", + body: "Missing error handling in the route handler.", + state: "CHANGES_REQUESTED", + submittedAt: "2026-03-01T12:00:00Z", + }, + ], + comments: [], +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("assessPRFeedback", () => { + it("returns no actionable feedback when reviews and comments are empty", async () => { + const result = await assessPRFeedback(mockSandbox, "recoupable/api", "Build feature X", noFeedback); + + expect(result.hasActionableFeedback).toBe(false); + expect(mockRunClaudeCodeAgent).not.toHaveBeenCalled(); + }); + + it("returns actionable feedback parsed from Claude Code response", async () => { + mockRunClaudeCodeAgent.mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify({ + hasActionableFeedback: true, + feedbackSummary: "Add error handling", + implementation: "Wrap the handler in a try/catch and return 500 on error", + }), + stderr: "", + }); + + const result = await assessPRFeedback( + mockSandbox, + "recoupable/api", + "Build feature X", + withFeedback, + ); + + expect(result.hasActionableFeedback).toBe(true); + expect(result.feedbackSummary).toBe("Add error handling"); + expect(result.implementation).toContain("try/catch"); + }); + + it("returns no actionable feedback when Claude Code exits non-zero", async () => { + mockRunClaudeCodeAgent.mockResolvedValueOnce({ + exitCode: 1, + stdout: "", + stderr: "error", + }); + + const result = await assessPRFeedback(mockSandbox, "recoupable/api", "Feature", withFeedback); + + expect(result.hasActionableFeedback).toBe(false); + }); + + it("returns no actionable feedback when JSON parsing fails", async () => { + mockRunClaudeCodeAgent.mockResolvedValueOnce({ + exitCode: 0, + stdout: "not valid json", + stderr: "", + }); + + const result = await assessPRFeedback(mockSandbox, "recoupable/api", "Feature", withFeedback); + + expect(result.hasActionableFeedback).toBe(false); + }); + + it("returns no actionable feedback when runClaudeCodeAgent throws", async () => { + mockRunClaudeCodeAgent.mockRejectedValueOnce(new Error("Sandbox error")); + + const result = await assessPRFeedback(mockSandbox, "recoupable/api", "Feature", withFeedback); + + expect(result.hasActionableFeedback).toBe(false); + }); +}); diff --git a/src/ai/__tests__/generateFeaturePrompt.test.ts b/src/ai/__tests__/generateFeaturePrompt.test.ts new file mode 100644 index 0000000..95c2b3b --- /dev/null +++ b/src/ai/__tests__/generateFeaturePrompt.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, + metadata: { set: vi.fn(), append: vi.fn() }, +})); + +const mockRunClaudeCodeAgent = vi.fn(); +vi.mock("../../sandboxes/runClaudeCodeAgent", () => ({ + runClaudeCodeAgent: (...args: unknown[]) => mockRunClaudeCodeAgent(...args), +})); + +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), +})); + +const { generateFeaturePrompt } = await import("../generateFeaturePrompt"); + +const mockSandbox = {} as any; + +const mockCommits = [ + { + submodule: "api", + repo: "recoupable/api", + commits: [ + { sha: "abc1234", message: "feat: add privy logins endpoint", author: "Dev", date: "2026-03-01T00:00:00Z" }, + ], + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("generateFeaturePrompt", () => { + it("returns the Claude Code generated prompt on success", async () => { + mockRunClaudeCodeAgent.mockResolvedValueOnce({ + exitCode: 0, + stdout: "Build a feature X in the api submodule.", + stderr: "", + }); + + const result = await generateFeaturePrompt(mockSandbox, mockCommits); + + expect(result).toBe("Build a feature X in the api submodule."); + expect(mockRunClaudeCodeAgent).toHaveBeenCalledWith( + mockSandbox, + expect.objectContaining({ + label: "Generate feature prompt", + message: expect.stringContaining("abc1234"), + }), + ); + }); + + it("returns fallback prompt when Claude Code exits non-zero", async () => { + mockRunClaudeCodeAgent.mockResolvedValueOnce({ + exitCode: 1, + stdout: "", + stderr: "error", + }); + + const result = await generateFeaturePrompt(mockSandbox, mockCommits); + + expect(result).toContain("PROGRESS.md"); + }); + + it("returns fallback prompt when Claude Code returns empty stdout", async () => { + mockRunClaudeCodeAgent.mockResolvedValueOnce({ + exitCode: 0, + stdout: "", + stderr: "", + }); + + const result = await generateFeaturePrompt(mockSandbox, mockCommits); + + expect(result).toContain("PROGRESS.md"); + }); + + it("returns fallback prompt when runClaudeCodeAgent throws", async () => { + mockRunClaudeCodeAgent.mockRejectedValueOnce(new Error("Sandbox error")); + + const result = await generateFeaturePrompt(mockSandbox, mockCommits); + + expect(result).toContain("PROGRESS.md"); + }); +}); diff --git a/src/ai/assessPRFeedback.ts b/src/ai/assessPRFeedback.ts new file mode 100644 index 0000000..a57c202 --- /dev/null +++ b/src/ai/assessPRFeedback.ts @@ -0,0 +1,93 @@ +import type { Sandbox } from "@vercel/sandbox"; +import type { PRFeedback } from "../github/fetchPRReviews"; +import { runClaudeCodeAgent } from "../sandboxes/runClaudeCodeAgent"; +import { logStep } from "../sandboxes/logStep"; + +export interface FeedbackAssessment { + hasActionableFeedback: boolean; + feedbackSummary: string; + implementation: string; +} + +/** + * Uses Claude Code in a sandbox to assess PR review feedback and determine + * whether any changes should be implemented by the coding agent. + * + * Ignores automated bot comments and approvals without substantive feedback. + * Returns a structured assessment with what (if anything) should be changed. + */ +export async function assessPRFeedback( + sandbox: Sandbox, + repo: string, + featureDescription: string, + feedback: PRFeedback, +): Promise { + const noFeedback: FeedbackAssessment = { + hasActionableFeedback: false, + feedbackSummary: "No feedback", + implementation: "", + }; + + if (feedback.reviews.length === 0 && feedback.comments.length === 0) { + return noFeedback; + } + + const reviewsText = feedback.reviews + .map((r) => `Review by ${r.author} (${r.state}): ${r.body}`) + .join("\n"); + + const commentsText = feedback.comments + .map((c) => `Comment by ${c.author} on ${c.path}: ${c.body}`) + .join("\n"); + + const feedbackText = [reviewsText, commentsText].filter(Boolean).join("\n\n"); + + const message = [ + `You are reviewing PR feedback for the repo "${repo}".`, + ``, + `Feature that was implemented: ${featureDescription.slice(0, 500)}`, + ``, + `PR feedback received:`, + feedbackText, + ``, + `Determine if there is actionable feedback that the coding agent should implement.`, + `Ignore: automated bot comments, approval messages, "LGTM", CI failure notices.`, + `Focus on: code quality issues, bugs, missing functionality, style violations.`, + ``, + `Respond with JSON only (no markdown):`, + `{"hasActionableFeedback": boolean, "feedbackSummary": "brief summary", "implementation": "specific changes to make, or empty string"}`, + ].join("\n"); + + try { + const result = await runClaudeCodeAgent(sandbox, { + label: `Assess PR feedback for ${repo}`, + message, + }); + + if (result.exitCode !== 0) { + logStep("Claude Code failed to assess PR feedback", false, { + exitCode: result.exitCode, + stderr: result.stderr.slice(-500), + }); + return noFeedback; + } + + const text = result.stdout.trim(); + + try { + const parsed = JSON.parse(text) as FeedbackAssessment; + logStep("PR feedback assessment complete", false, { + repo, + hasActionableFeedback: parsed.hasActionableFeedback, + summary: parsed.feedbackSummary, + }); + return parsed; + } catch { + logStep("Failed to parse feedback assessment JSON", false, { text: text.slice(0, 200) }); + return noFeedback; + } + } catch (error) { + logStep("Failed to assess PR feedback", false, { error: String(error) }); + return noFeedback; + } +} diff --git a/src/ai/generateFeaturePrompt.ts b/src/ai/generateFeaturePrompt.ts new file mode 100644 index 0000000..3f08499 --- /dev/null +++ b/src/ai/generateFeaturePrompt.ts @@ -0,0 +1,78 @@ +import type { Sandbox } from "@vercel/sandbox"; +import type { SubmoduleCommit } from "../github/fetchRecentSubmoduleCommits"; +import { runClaudeCodeAgent } from "../sandboxes/runClaudeCodeAgent"; +import { logStep } from "../sandboxes/logStep"; +import { getFallbackPrompt } from "./getFallbackPrompt"; + +const SYSTEM_CONTEXT = `You are a senior software engineer on the Recoupable platform — a music industry management tool for record labels and artist managers. + +The platform has these main components: +- chat: Next.js frontend where music managers chat with their AI agent +- api: Backend API (Next.js) with AI/MCP tools, Supabase DB, Slack bot integration +- tasks: Trigger.dev background jobs (pulse emails, content creation, coding agent) +- admin: Internal admin dashboard (Next.js) +- cli: Command-line interface for power users +- docs: API documentation (Mintlify) + +Your task: analyze recent commits and propose the single most valuable small feature to implement next. + +Rules: +- Pick something that builds naturally on recent work +- Keep it focused — a single, shippable improvement +- Favor real user value (music managers need to manage artists, track metrics, send communications) +- DO NOT suggest refactors, tests, or documentation updates + +Respond with ONLY an implementation prompt for an AI coding agent. The prompt should: +- Say exactly what to build and in which submodule(s) +- Reference specific files/routes/components when relevant +- Include clear acceptance criteria +- NOT ask for planning or approval — just direct the agent to implement it`; + +/** + * Uses Claude Code in a sandbox to generate an actionable feature implementation prompt + * based on the recent commit history across monorepo submodules. + * + * Falls back to a generic improvement prompt if the sandbox call fails. + */ +export async function generateFeaturePrompt( + sandbox: Sandbox, + recentCommits: SubmoduleCommit[], +): Promise { + const commitsContext = recentCommits + .map( + ({ submodule, commits }) => + `### ${submodule}\n${commits.map((c) => `- ${c.sha} ${c.message} (${c.date.slice(0, 10)})`).join("\n")}`, + ) + .join("\n\n"); + + const message = `${SYSTEM_CONTEXT} + +Here are the most recent commits across the Recoupable monorepo: + +${commitsContext} + +Based on this recent work, write a specific implementation prompt for an AI coding agent to implement the next most valuable feature.`; + + try { + const result = await runClaudeCodeAgent(sandbox, { + label: "Generate feature prompt", + message, + }); + + const text = result.stdout.trim(); + + if (result.exitCode !== 0 || !text) { + logStep("Claude Code failed to generate feature prompt", false, { + exitCode: result.exitCode, + stderr: result.stderr.slice(-500), + }); + return getFallbackPrompt(); + } + + logStep("Generated Agent Day feature prompt", false, { preview: text.slice(0, 200) }); + return text; + } catch (error) { + logStep("Failed to generate feature prompt", false, { error: String(error) }); + return getFallbackPrompt(); + } +} diff --git a/src/ai/getFallbackPrompt.ts b/src/ai/getFallbackPrompt.ts new file mode 100644 index 0000000..5053314 --- /dev/null +++ b/src/ai/getFallbackPrompt.ts @@ -0,0 +1,16 @@ +/** + * Returns a generic feature implementation prompt used as a fallback + * when Claude Code fails to generate a specific one from recent commits. + */ +export function getFallbackPrompt(): string { + return [ + "Read PROGRESS_USAGE.md and PROGRESS.md in the mono repo codebase first.", + "", + "Review the last 10 commits across the api, chat, admin, and tasks submodules.", + "Identify the single most impactful small improvement that builds on recent work", + "— a bug fix, a missing endpoint, a UI polish, or a small new feature.", + "", + "Implement it end-to-end (API route + frontend if needed), write any relevant tests,", + "then update PROGRESS.md with what you built.", + ].join("\n"); +} diff --git a/src/github/__tests__/fetchPRReviews.test.ts b/src/github/__tests__/fetchPRReviews.test.ts new file mode 100644 index 0000000..f7a1952 --- /dev/null +++ b/src/github/__tests__/fetchPRReviews.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { warn: vi.fn() }, +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const { fetchPRReviews } = await import("../fetchPRReviews"); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_TOKEN = "test-token"; +}); + +describe("fetchPRReviews", () => { + it("returns reviews and inline comments", async () => { + const mockReview = { + user: { login: "reviewer" }, + body: "Please fix the error handling.", + state: "CHANGES_REQUESTED", + submitted_at: "2026-03-01T12:00:00Z", + }; + const mockComment = { + user: { login: "reviewer" }, + body: "This variable name is unclear.", + path: "lib/api/handler.ts", + created_at: "2026-03-01T12:05:00Z", + }; + + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => [mockReview] }) + .mockResolvedValueOnce({ ok: true, json: async () => [mockComment] }); + + const result = await fetchPRReviews("recoupable/api", 42); + + expect(result.reviews).toHaveLength(1); + expect(result.reviews[0].author).toBe("reviewer"); + expect(result.reviews[0].body).toBe("Please fix the error handling."); + expect(result.reviews[0].state).toBe("CHANGES_REQUESTED"); + + expect(result.comments).toHaveLength(1); + expect(result.comments[0].author).toBe("reviewer"); + expect(result.comments[0].path).toBe("lib/api/handler.ts"); + }); + + it("filters out reviews with empty bodies", async () => { + const mockReview = { user: { login: "bot" }, body: "", state: "APPROVED", submitted_at: "" }; + + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => [mockReview] }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }); + + const result = await fetchPRReviews("recoupable/api", 1); + + expect(result.reviews).toHaveLength(0); + }); + + it("returns empty arrays when fetches fail", async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 403 }) + .mockResolvedValueOnce({ ok: false, status: 403 }); + + const result = await fetchPRReviews("recoupable/api", 99); + + expect(result.reviews).toEqual([]); + expect(result.comments).toEqual([]); + }); +}); diff --git a/src/github/__tests__/fetchRecentSubmoduleCommits.test.ts b/src/github/__tests__/fetchRecentSubmoduleCommits.test.ts new file mode 100644 index 0000000..4c4ee7c --- /dev/null +++ b/src/github/__tests__/fetchRecentSubmoduleCommits.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { warn: vi.fn() }, +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const { fetchRecentSubmoduleCommits } = await import("../fetchRecentSubmoduleCommits"); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_TOKEN = "test-token"; +}); + +describe("fetchRecentSubmoduleCommits", () => { + it("fetches commits for all submodules and formats them", async () => { + const mockCommit = { + sha: "abc1234def5678", + commit: { + message: "feat: add new feature\n\nLonger description", + author: { name: "Dev", date: "2026-03-01T10:00:00Z" }, + }, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [mockCommit], + }); + + const results = await fetchRecentSubmoduleCommits(); + + expect(results.length).toBeGreaterThan(0); + + const apiResult = results.find((r) => r.submodule === "api"); + expect(apiResult).toBeDefined(); + expect(apiResult!.repo).toBe("recoupable/api"); + expect(apiResult!.commits[0].sha).toBe("abc1234"); // first 7 chars + expect(apiResult!.commits[0].message).toBe("feat: add new feature"); // first line only + expect(apiResult!.commits[0].author).toBe("Dev"); + }); + + it("skips repos where the fetch fails", async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 404 }) // api fails + .mockResolvedValue({ ok: true, json: async () => [] }); // rest succeed + + const results = await fetchRecentSubmoduleCommits(); + + const apiResult = results.find((r) => r.submodule === "api"); + expect(apiResult).toBeUndefined(); + }); + + it("skips repos where fetch throws", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + mockFetch.mockResolvedValue({ ok: true, json: async () => [] }); + + const results = await fetchRecentSubmoduleCommits(); + // Should not throw, just skip the errored repo + expect(Array.isArray(results)).toBe(true); + }); + + it("uses Authorization header with GITHUB_TOKEN", async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => [] }); + + await fetchRecentSubmoduleCommits(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "token test-token", + }), + }), + ); + }); +}); diff --git a/src/github/__tests__/mergePR.test.ts b/src/github/__tests__/mergePR.test.ts new file mode 100644 index 0000000..9b015b2 --- /dev/null +++ b/src/github/__tests__/mergePR.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { log: vi.fn(), error: vi.fn() }, +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const { mergePR } = await import("../mergePR"); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_TOKEN = "test-token"; +}); + +describe("mergePR", () => { + it("merges the PR and returns true on success", async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + const result = await mergePR("recoupable/api", 42); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.github.com/repos/recoupable/api/pulls/42/merge", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + Authorization: "token test-token", + }), + }), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.merge_method).toBe("squash"); + }); + + it("returns false when merge fails", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 405, + text: async () => "Pull Request is not mergeable", + }); + + const result = await mergePR("recoupable/api", 42); + + expect(result).toBe(false); + }); +}); diff --git a/src/github/fetchPRReviews.ts b/src/github/fetchPRReviews.ts new file mode 100644 index 0000000..a376a01 --- /dev/null +++ b/src/github/fetchPRReviews.ts @@ -0,0 +1,84 @@ +import { logger } from "@trigger.dev/sdk/v3"; + +export interface PRReview { + author: string; + body: string; + state: string; + submittedAt: string; +} + +export interface PRLineComment { + author: string; + body: string; + path: string; + createdAt: string; +} + +export interface PRFeedback { + reviews: PRReview[]; + comments: PRLineComment[]; +} + +/** + * Fetches review bodies and inline comments from a GitHub PR. + * + * @param repo - GitHub repo in "owner/repo" format + * @param prNumber - The PR number + */ +export async function fetchPRReviews(repo: string, prNumber: number): Promise { + const token = process.env.GITHUB_TOKEN; + const headers = { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }; + + const [reviewsRes, commentsRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, { headers }), + fetch(`https://api.github.com/repos/${repo}/pulls/${prNumber}/comments`, { headers }), + ]); + + const reviews: PRReview[] = []; + const comments: PRLineComment[] = []; + + if (reviewsRes.ok) { + const data = (await reviewsRes.json()) as Array<{ + user: { login: string }; + body: string; + state: string; + submitted_at: string; + }>; + reviews.push( + ...data + .filter((r) => r.body?.trim()) + .map((r) => ({ + author: r.user.login, + body: r.body, + state: r.state, + submittedAt: r.submitted_at, + })), + ); + } else { + logger.warn(`Failed to fetch reviews for PR #${prNumber} in ${repo}`); + } + + if (commentsRes.ok) { + const data = (await commentsRes.json()) as Array<{ + user: { login: string }; + body: string; + path: string; + created_at: string; + }>; + comments.push( + ...data.map((c) => ({ + author: c.user.login, + body: c.body, + path: c.path, + createdAt: c.created_at, + })), + ); + } else { + logger.warn(`Failed to fetch comments for PR #${prNumber} in ${repo}`); + } + + return { reviews, comments }; +} diff --git a/src/github/fetchRecentSubmoduleCommits.ts b/src/github/fetchRecentSubmoduleCommits.ts new file mode 100644 index 0000000..7bcfc26 --- /dev/null +++ b/src/github/fetchRecentSubmoduleCommits.ts @@ -0,0 +1,56 @@ +import { logger } from "@trigger.dev/sdk/v3"; +import { SUBMODULE_CONFIG } from "../sandboxes/submoduleConfig"; + +export interface SubmoduleCommit { + submodule: string; + repo: string; + commits: { sha: string; message: string; author: string; date: string }[]; +} + +/** + * Fetches the 5 most recent commits from each submodule repo via the GitHub REST API. + * Skips repos where the request fails (e.g. missing token or rate limit). + */ +export async function fetchRecentSubmoduleCommits(): Promise { + const token = process.env.GITHUB_TOKEN; + const results: SubmoduleCommit[] = []; + + for (const [submodule, { repo }] of Object.entries(SUBMODULE_CONFIG)) { + try { + const response = await fetch( + `https://api.github.com/repos/${repo}/commits?per_page=5`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (!response.ok) { + logger.warn(`Failed to fetch commits for ${repo}`, { status: response.status }); + continue; + } + + const data = (await response.json()) as Array<{ + sha: string; + commit: { message: string; author: { name: string; date: string } }; + }>; + + results.push({ + submodule, + repo, + commits: data.map((c) => ({ + sha: c.sha.slice(0, 7), + message: c.commit.message.split("\n")[0], + author: c.commit.author.name, + date: c.commit.author.date, + })), + }); + } catch (error) { + logger.warn(`Error fetching commits for ${repo}`, { error }); + } + } + + return results; +} diff --git a/src/github/getVercelPreviewUrl.ts b/src/github/getVercelPreviewUrl.ts new file mode 100644 index 0000000..a7a8f72 --- /dev/null +++ b/src/github/getVercelPreviewUrl.ts @@ -0,0 +1,74 @@ +import { logger } from "@trigger.dev/sdk/v3"; + +/** + * Finds the Vercel preview deployment URL for a PR by inspecting + * check runs created by the Vercel GitHub integration. + * Returns null if no Vercel deployment is found. + * + * @param repo - GitHub repo in "owner/repo" format + * @param prNumber - The PR number + */ +export async function getVercelPreviewUrl( + repo: string, + prNumber: number, +): Promise { + const token = process.env.GITHUB_TOKEN; + const headers = { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }; + + const prRes = await fetch(`https://api.github.com/repos/${repo}/pulls/${prNumber}`, { + headers, + }); + + if (!prRes.ok) return null; + + const pr = (await prRes.json()) as { head: { sha: string } }; + + const checksRes = await fetch( + `https://api.github.com/repos/${repo}/commits/${pr.head.sha}/check-runs`, + { headers }, + ); + + if (!checksRes.ok) return null; + + const checksData = (await checksRes.json()) as { + check_runs: Array<{ name: string; details_url: string | null; conclusion: string | null }>; + }; + + const vercelCheck = checksData.check_runs.find( + (c) => c.name.toLowerCase().includes("vercel") && c.details_url?.includes("vercel.app"), + ); + + if (!vercelCheck?.details_url) return null; + + // Vercel details_url is the inspect URL: https://vercel.com/... + // Extract the preview deployment URL from the deployment status instead + const statusRes = await fetch( + `https://api.github.com/repos/${repo}/commits/${pr.head.sha}/statuses`, + { headers }, + ); + + if (!statusRes.ok) return null; + + const statuses = (await statusRes.json()) as Array<{ + context: string; + target_url: string; + state: string; + }>; + + const vercelStatus = statuses.find( + (s) => + s.context.toLowerCase().includes("vercel") && + s.state === "success" && + s.target_url?.includes("vercel.app"), + ); + + if (vercelStatus?.target_url) { + logger.log(`Found Vercel preview URL for PR #${prNumber}`, { url: vercelStatus.target_url }); + return vercelStatus.target_url; + } + + return null; +} diff --git a/src/github/mergePR.ts b/src/github/mergePR.ts new file mode 100644 index 0000000..5768b70 --- /dev/null +++ b/src/github/mergePR.ts @@ -0,0 +1,39 @@ +import { logger } from "@trigger.dev/sdk/v3"; + +/** + * Merges a GitHub PR using the squash merge strategy. + * Returns true if the merge was successful, false otherwise. + * + * @param repo - GitHub repo in "owner/repo" format + * @param prNumber - The PR number to merge + */ +export async function mergePR(repo: string, prNumber: number): Promise { + const token = process.env.GITHUB_TOKEN; + + const response = await fetch( + `https://api.github.com/repos/${repo}/pulls/${prNumber}/merge`, + { + method: "PUT", + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + merge_method: "squash", + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + logger.error(`Failed to merge PR #${prNumber} in ${repo}`, { + status: response.status, + error: errorText, + }); + return false; + } + + logger.log(`Successfully merged PR #${prNumber} in ${repo}`); + return true; +} diff --git a/src/github/waitForPRChecks.ts b/src/github/waitForPRChecks.ts new file mode 100644 index 0000000..143b47b --- /dev/null +++ b/src/github/waitForPRChecks.ts @@ -0,0 +1,91 @@ +import { wait } from "@trigger.dev/sdk/v3"; +import { logStep } from "../sandboxes/logStep"; + +export interface PRCheckResult { + allPassed: boolean; + failedChecks: string[]; + pendingChecks: string[]; +} + +/** + * Polls the GitHub API until all check runs on a PR complete or the timeout is reached. + * Returns whether all checks passed and a list of any failed check names. + * + * @param repo - GitHub repo in "owner/repo" format + * @param prNumber - The PR number + * @param maxWaitMs - How long to wait before giving up (default 15 minutes) + */ +export async function waitForPRChecks( + repo: string, + prNumber: number, + maxWaitMs: number = 15 * 60 * 1000, +): Promise { + const token = process.env.GITHUB_TOKEN; + const headers = { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }; + const startTime = Date.now(); + + // Get the head SHA from the PR + const prRes = await fetch(`https://api.github.com/repos/${repo}/pulls/${prNumber}`, { + headers, + }); + + if (!prRes.ok) { + logStep(`Failed to fetch PR #${prNumber} from ${repo}`, false, { status: prRes.status }); + return { allPassed: false, failedChecks: [], pendingChecks: ["fetch-failed"] }; + } + + const pr = (await prRes.json()) as { head: { sha: string } }; + const headSha = pr.head.sha; + + while (Date.now() - startTime < maxWaitMs) { + const checksRes = await fetch( + `https://api.github.com/repos/${repo}/commits/${headSha}/check-runs`, + { headers }, + ); + + if (!checksRes.ok) { + logStep(`Failed to fetch check runs for ${repo}/${headSha}`, false); + return { allPassed: false, failedChecks: [], pendingChecks: ["fetch-failed"] }; + } + + const checksData = (await checksRes.json()) as { + check_runs: Array<{ name: string; status: string; conclusion: string | null }>; + }; + + const checks = checksData.check_runs; + + if (checks.length > 0) { + const pending = checks.filter((c) => c.status !== "completed"); + const failed = checks.filter( + (c) => + c.status === "completed" && + c.conclusion !== "success" && + c.conclusion !== "skipped" && + c.conclusion !== "neutral", + ); + + if (pending.length === 0) { + logStep(`All checks complete for PR #${prNumber}`, false, { + total: checks.length, + failed: failed.length, + }); + return { + allPassed: failed.length === 0, + failedChecks: failed.map((c) => c.name), + pendingChecks: [], + }; + } + + logStep(`Waiting for ${pending.length} checks on PR #${prNumber}`, false, { + pending: pending.map((c) => c.name), + }); + } + + await wait.for({ seconds: 30 }); + } + + return { allPassed: false, failedChecks: [], pendingChecks: ["timeout"] }; +} diff --git a/src/schemas/codingAgentSchema.ts b/src/schemas/codingAgentSchema.ts index 150289f..b098f02 100644 --- a/src/schemas/codingAgentSchema.ts +++ b/src/schemas/codingAgentSchema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export const codingAgentPayloadSchema = z.object({ prompt: z.string().min(1, "prompt is required"), - callbackThreadId: z.string().min(1, "callbackThreadId is required"), + callbackThreadId: z.string().optional(), }); export type CodingAgentPayload = z.infer; diff --git a/src/schemas/updatePRSchema.ts b/src/schemas/updatePRSchema.ts index 50484f9..d6e7bcc 100644 --- a/src/schemas/updatePRSchema.ts +++ b/src/schemas/updatePRSchema.ts @@ -5,7 +5,7 @@ export const updatePRPayloadSchema = z.object({ snapshotId: z.string().min(1, "snapshotId is required"), branch: z.string().min(1, "branch is required"), repo: z.string().min(1, "repo is required"), - callbackThreadId: z.string().min(1, "callbackThreadId is required"), + callbackThreadId: z.string().optional(), }); export type UpdatePRPayload = z.infer; diff --git a/src/slack/__tests__/postToSlackChannel.test.ts b/src/slack/__tests__/postToSlackChannel.test.ts new file mode 100644 index 0000000..aa886ed --- /dev/null +++ b/src/slack/__tests__/postToSlackChannel.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { log: vi.fn(), error: vi.fn() }, + metadata: { set: vi.fn(), append: vi.fn() }, +})); + +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const { postToSlackChannel } = await import("../postToSlackChannel"); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.SLACK_BOT_TOKEN = "xoxb-test-token"; +}); + +describe("postToSlackChannel", () => { + it("posts a message to the correct channel and returns true", async () => { + mockFetch.mockResolvedValueOnce({ json: async () => ({ ok: true }) }); + + const result = await postToSlackChannel("C08HN8RKJHZ", "Hello, world!"); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + "https://slack.com/api/chat.postMessage", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer xoxb-test-token", + "Content-Type": "application/json", + }), + }), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.channel).toBe("C08HN8RKJHZ"); + expect(body.text).toBe("Hello, world!"); + }); + + it("returns false when Slack API returns ok: false", async () => { + mockFetch.mockResolvedValueOnce({ + json: async () => ({ ok: false, error: "channel_not_found" }), + }); + + const result = await postToSlackChannel("CINVALID", "Test"); + + expect(result).toBe(false); + }); + + it("returns false and logs error when SLACK_BOT_TOKEN is missing", async () => { + delete process.env.SLACK_BOT_TOKEN; + const { logger } = await import("@trigger.dev/sdk/v3"); + + const result = await postToSlackChannel("C08HN8RKJHZ", "Test"); + + expect(result).toBe(false); + expect(mockFetch).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("SLACK_BOT_TOKEN")); + }); +}); diff --git a/src/slack/postToSlackChannel.ts b/src/slack/postToSlackChannel.ts new file mode 100644 index 0000000..6806c42 --- /dev/null +++ b/src/slack/postToSlackChannel.ts @@ -0,0 +1,37 @@ +import { logger } from "@trigger.dev/sdk/v3"; +import { logStep } from "../sandboxes/logStep"; + +/** + * Posts a plain-text message to a Slack channel using the bot token. + * Returns true if the message was sent successfully. + * + * @param channelId - The Slack channel ID (e.g. "C08HN8RKJHZ") + * @param text - The message text (supports Slack mrkdwn formatting) + */ +export async function postToSlackChannel(channelId: string, text: string): Promise { + const token = process.env.SLACK_BOT_TOKEN; + + if (!token) { + logger.error("Missing SLACK_BOT_TOKEN — cannot post to Slack"); + return false; + } + + const response = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ channel: channelId, text }), + }); + + const data = (await response.json()) as { ok: boolean; error?: string }; + + if (!data.ok) { + logger.error("Failed to post message to Slack", { channel: channelId, error: data.error }); + return false; + } + + logStep("Posted message to Slack", false, { channel: channelId }); + return true; +} diff --git a/src/tasks/agentDayTask.ts b/src/tasks/agentDayTask.ts new file mode 100644 index 0000000..3c19d1c --- /dev/null +++ b/src/tasks/agentDayTask.ts @@ -0,0 +1,206 @@ +import { logger, schedules, wait } from "@trigger.dev/sdk/v3"; +import { codingAgentTask } from "./codingAgentTask"; +import { updatePRTask } from "./updatePRTask"; +import { fetchRecentSubmoduleCommits } from "../github/fetchRecentSubmoduleCommits"; +import { generateFeaturePrompt } from "../ai/generateFeaturePrompt"; +import { waitForPRChecks } from "../github/waitForPRChecks"; +import { fetchPRReviews } from "../github/fetchPRReviews"; +import { assessPRFeedback } from "../ai/assessPRFeedback"; +import { getVercelPreviewUrl } from "../github/getVercelPreviewUrl"; +import { postToSlackChannel } from "../slack/postToSlackChannel"; +import { sendTelegramMessage } from "../telegram/sendTelegramMessage"; +import { logStep } from "../sandboxes/logStep"; +import { getOrCreateSandbox } from "../sandboxes/getOrCreateSandbox"; +import { CODING_AGENT_ACCOUNT_ID } from "../consts"; + +const AGENT_DAY_SLACK_CHANNEL = "C08HN8RKJHZ"; +const MAX_REVIEW_ITERATIONS = 3; +const PR_CHECK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes + +/** + * Scheduled task that runs every Sunday at 10 AM ET and autonomously + * implements a new feature end-to-end: + * + * 1. Gathers recent commits across all submodules + * 2. Uses Claude to plan a focused feature based on recent work + * 3. Triggers the coding-agent task to implement it and open PRs + * 4. Reviews each PR: waits for checks, assesses human feedback, applies fixes + * 5. Tests Vercel preview deployments (api/chat repos) + * 6. Sends Telegram notification with PR links for human review/merge + * 7. Posts a summary to the #dev Slack channel + */ +export const agentDayTask = schedules.task({ + id: "agent-day", + cron: { pattern: "0 10 * * 0", timezone: "America/New_York" }, // 10 AM ET every Sunday + maxDuration: 60 * 120, // 2 hours + run: async (payload) => { + logStep("Agent Day task started", true, { + timestamp: payload.timestamp, + date: new Date(payload.timestamp).toDateString(), + }); + + // Create a sandbox for AI reasoning (feature planning + feedback assessment) + logStep("Creating sandbox for AI reasoning"); + const { sandbox: aiSandbox } = await getOrCreateSandbox(CODING_AGENT_ACCOUNT_ID); + + try { + // Step 1: Gather recent commits to understand what has been built recently + logStep("Fetching recent commits from all submodules"); + const recentCommits = await fetchRecentSubmoduleCommits(); + logStep("Recent commits fetched", true, { submoduleCount: recentCommits.length }); + + // Step 2: Use Claude Code in sandbox to plan the next feature based on recent work + logStep("Generating feature prompt from recent commits"); + const featurePrompt = await generateFeaturePrompt(aiSandbox, recentCommits); + logStep("Feature prompt generated", true, { preview: featurePrompt.slice(0, 300) }); + + // Step 3 & 4: Trigger the coding agent to implement the feature and open PRs + logStep("Triggering coding agent"); + const codingResult = await codingAgentTask.triggerAndWait({ prompt: featurePrompt }); + + if (!codingResult.ok) { + logger.error("Coding agent task failed", { error: codingResult.error }); + await postToSlackChannel( + AGENT_DAY_SLACK_CHANNEL, + `🤖 *Agent Day — ${new Date(payload.timestamp).toDateString()}*\n\nCoding agent failed. Check Trigger.dev for details.`, + ); + return { success: false, error: "Coding agent failed" }; + } + + const { branch, snapshotId, prs } = codingResult.output; + logStep("Coding agent completed", true, { branch, prCount: prs.length, prs }); + + if (prs.length === 0) { + await postToSlackChannel( + AGENT_DAY_SLACK_CHANNEL, + `🤖 *Agent Day — ${new Date(payload.timestamp).toDateString()}*\n\nNo changes were made — the agent found nothing to implement.`, + ); + return { success: true, prs: [], featurePrompt }; + } + + // Step 5: Review each PR — wait for checks, implement feedback, test preview + const reviewedPRs: typeof prs = []; + let currentSnapshotId = snapshotId; + + for (const pr of prs) { + logStep(`Reviewing PR #${pr.number} in ${pr.repo}`); + + // Step 5a: Wait for all CI checks to complete + logStep(`Waiting for checks on PR #${pr.number}`); + const checkResult = await waitForPRChecks(pr.repo, pr.number, PR_CHECK_TIMEOUT_MS); + logStep(`Checks complete for PR #${pr.number}`, true, { + allPassed: checkResult.allPassed, + failedChecks: checkResult.failedChecks, + }); + + let reviewSnapshotId = currentSnapshotId; + + // Step 5b-5c: PR review loop — assess feedback and implement if needed + for (let iteration = 0; iteration < MAX_REVIEW_ITERATIONS; iteration++) { + logStep(`Review iteration ${iteration + 1} for PR #${pr.number}`); + + const feedback = await fetchPRReviews(pr.repo, pr.number); + const assessment = await assessPRFeedback(aiSandbox, pr.repo, featurePrompt, feedback); + + logStep(`Feedback assessed for PR #${pr.number}`, true, { + hasActionableFeedback: assessment.hasActionableFeedback, + summary: assessment.feedbackSummary, + }); + + if (!assessment.hasActionableFeedback) { + break; // Nothing to address — move on + } + + // Step 5c: Apply the feedback via the update-pr task + logStep(`Applying feedback for PR #${pr.number}: ${assessment.feedbackSummary}`); + const updateResult = await updatePRTask.triggerAndWait({ + feedback: assessment.implementation, + snapshotId: reviewSnapshotId, + branch, + repo: pr.repo, + }); + + if (!updateResult.ok) { + logger.warn(`Failed to apply feedback for PR #${pr.number}`, { + error: updateResult.error, + }); + break; + } + + reviewSnapshotId = updateResult.output.snapshotId; + currentSnapshotId = reviewSnapshotId; + + // Wait for the new commit to be picked up by CI before re-checking + await wait.for({ seconds: 30 }); + } + + // Step 5d: Test Vercel preview deployment for api and chat repos + if (pr.repo === "recoupable/api" || pr.repo === "recoupable/chat") { + const previewUrl = await getVercelPreviewUrl(pr.repo, pr.number); + + if (previewUrl) { + logStep(`Testing Vercel preview for PR #${pr.number}`, true, { previewUrl }); + try { + const healthRes = await fetch(`${previewUrl}/api/health`, { + signal: AbortSignal.timeout(10_000), + }); + logStep(`Preview health check for PR #${pr.number}`, true, { + url: `${previewUrl}/api/health`, + status: healthRes.status, + }); + } catch (error) { + logger.warn(`Preview health check failed for PR #${pr.number}`, { error }); + } + } else { + logStep(`No Vercel preview URL found for PR #${pr.number}`); + } + } + + reviewedPRs.push(pr); + } + + // Step 6: Send Telegram notification with PR links for human review/merge + const date = new Date(payload.timestamp).toDateString(); + const prLines = reviewedPRs + .map((pr) => `• ${pr.repo} #${pr.number}`) + .join("\n"); + + const telegramMessage = [ + `🤖 Agent Day — ${date}`, + ``, + `${reviewedPRs.length} PR${reviewedPRs.length !== 1 ? "s" : ""} ready for review:`, + prLines, + ``, + `Feature: ${featurePrompt.slice(0, 280)}${featurePrompt.length > 280 ? "…" : ""}`, + ].join("\n"); + + await sendTelegramMessage(telegramMessage); + + // Step 7: Post summary to Slack + const slackPrLines = reviewedPRs + .map((pr) => `• <${pr.url}|${pr.repo} #${pr.number}>`) + .join("\n"); + + const slackLines = [ + `🤖 *Agent Day — ${date}*`, + ``, + `Opened *${reviewedPRs.length}* PR${reviewedPRs.length !== 1 ? "s" : ""} for review:`, + slackPrLines || "_(none)_", + ``, + `*Feature:* ${featurePrompt.slice(0, 280)}${featurePrompt.length > 280 ? "…" : ""}`, + ]; + + await postToSlackChannel(AGENT_DAY_SLACK_CHANNEL, slackLines.filter(Boolean).join("\n")); + + logStep("Agent Day task completed", true, { + reviewedPRs: reviewedPRs.length, + totalPRs: prs.length, + }); + + return { success: true, prs: reviewedPRs, featurePrompt }; + } finally { + logStep("Stopping AI reasoning sandbox", false); + await aiSandbox.stop(); + } + }, +}); diff --git a/src/tasks/codingAgentTask.ts b/src/tasks/codingAgentTask.ts index 18e1f92..69cc72e 100644 --- a/src/tasks/codingAgentTask.ts +++ b/src/tasks/codingAgentTask.ts @@ -64,17 +64,19 @@ export const codingAgentTask = schemaTask({ logStep("Taking snapshot"); const { snapshotId } = await sandbox.snapshot(); - const callbackPayload = { - threadId: callbackThreadId, - status: (prs.length > 0 ? "pr_created" : "no_changes") as "pr_created" | "no_changes", - branch, - snapshotId, - prs, - stdout: agentResult.stdout, - stderr: agentResult.stderr, - }; - logStep("Notifying bot", true, callbackPayload); - await notifyCodingAgentCallback(callbackPayload); + if (callbackThreadId) { + const callbackPayload = { + threadId: callbackThreadId, + status: (prs.length > 0 ? "pr_created" : "no_changes") as "pr_created" | "no_changes", + branch, + snapshotId, + prs, + stdout: agentResult.stdout, + stderr: agentResult.stderr, + }; + logStep("Notifying bot", true, callbackPayload); + await notifyCodingAgentCallback(callbackPayload); + } metadata.set("currentStep", "Complete"); diff --git a/src/tasks/updatePRTask.ts b/src/tasks/updatePRTask.ts index 8aa1464..f608b33 100644 --- a/src/tasks/updatePRTask.ts +++ b/src/tasks/updatePRTask.ts @@ -69,15 +69,17 @@ export const updatePRTask = schemaTask({ logStep("Taking new snapshot"); const newSnapshot = await sandbox.snapshot(); - const callbackPayload = { - threadId: callbackThreadId, - status: "updated" as const, - snapshotId: newSnapshot.snapshotId, - stdout: agentResult.stdout, - stderr: agentResult.stderr, - }; - logStep("Notifying bot", true, callbackPayload); - await notifyCodingAgentCallback(callbackPayload); + if (callbackThreadId) { + const callbackPayload = { + threadId: callbackThreadId, + status: "updated" as const, + snapshotId: newSnapshot.snapshotId, + stdout: agentResult.stdout, + stderr: agentResult.stderr, + }; + logStep("Notifying bot", true, callbackPayload); + await notifyCodingAgentCallback(callbackPayload); + } metadata.set("currentStep", "Complete"); diff --git a/src/telegram/__tests__/sendTelegramMessage.test.ts b/src/telegram/__tests__/sendTelegramMessage.test.ts new file mode 100644 index 0000000..48791e3 --- /dev/null +++ b/src/telegram/__tests__/sendTelegramMessage.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { log: vi.fn(), error: vi.fn() }, + metadata: { set: vi.fn(), append: vi.fn() }, +})); + +vi.mock("../../sandboxes/logStep", () => ({ + logStep: vi.fn(), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const { sendTelegramMessage } = await import("../sendTelegramMessage"); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.TELEGRAM_BOT_TOKEN = "123:ABC"; + process.env.TELEGRAM_CHAT_ID = "456"; +}); + +describe("sendTelegramMessage", () => { + it("sends a message and returns true on success", async () => { + mockFetch.mockResolvedValueOnce({ json: async () => ({ ok: true }) }); + + const result = await sendTelegramMessage("Hello!"); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.telegram.org/bot123:ABC/sendMessage", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.chat_id).toBe("456"); + expect(body.text).toBe("Hello!"); + expect(body.parse_mode).toBe("HTML"); + }); + + it("returns false when Telegram API returns ok: false", async () => { + mockFetch.mockResolvedValueOnce({ + json: async () => ({ ok: false, description: "Bad Request" }), + }); + + const result = await sendTelegramMessage("Test"); + + expect(result).toBe(false); + }); + + it("returns false when TELEGRAM_BOT_TOKEN is missing", async () => { + delete process.env.TELEGRAM_BOT_TOKEN; + + const result = await sendTelegramMessage("Test"); + + expect(result).toBe(false); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns false when TELEGRAM_CHAT_ID is missing", async () => { + delete process.env.TELEGRAM_CHAT_ID; + + const result = await sendTelegramMessage("Test"); + + expect(result).toBe(false); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/sendTelegramMessage.ts b/src/telegram/sendTelegramMessage.ts new file mode 100644 index 0000000..87c97d4 --- /dev/null +++ b/src/telegram/sendTelegramMessage.ts @@ -0,0 +1,42 @@ +import { logger } from "@trigger.dev/sdk/v3"; +import { logStep } from "../sandboxes/logStep"; + +/** + * Sends a message to a Telegram chat using the Bot API. + * Returns true if the message was sent successfully. + * + * @param text - The message text (supports Telegram MarkdownV2 or HTML) + * @param parseMode - Optional parse mode ("MarkdownV2" or "HTML") + */ +export async function sendTelegramMessage( + text: string, + parseMode: "MarkdownV2" | "HTML" = "HTML", +): Promise { + const token = process.env.TELEGRAM_BOT_TOKEN; + const chatId = process.env.TELEGRAM_CHAT_ID; + + if (!token || !chatId) { + logger.error("Missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID"); + return false; + } + + const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: chatId, + text, + parse_mode: parseMode, + }), + }); + + const data = (await response.json()) as { ok: boolean; description?: string }; + + if (!data.ok) { + logger.error("Failed to send Telegram message", { error: data.description }); + return false; + } + + logStep("Sent Telegram notification", false); + return true; +}