diff --git a/.env.example b/.env.example index e92054c..467269d 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ DEV_OVERRIDE_PRICING_TIER= DEV_OVERRIDE_PRICING_TIER_USER_IDS= FINNHUB_API_KEY= FINNHUB_BASE_URL= +MISTRAL_API_KEY= +MISTRAL_PR_REVIEW_MODEL= NEXT_PUBLIC_APPWRITE_ENDPOINT= NEXT_PUBLIC_APPWRITE_PROJECT_ID= NEXT_PUBLIC_GTM_ID= diff --git a/.github/workflows/mistral-pr-review.yml b/.github/workflows/mistral-pr-review.yml new file mode 100644 index 0000000..dee79ac --- /dev/null +++ b/.github/workflows/mistral-pr-review.yml @@ -0,0 +1,35 @@ +name: Mistral PR review + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: mistral-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + mistral-review: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Mistral code review comment + env: + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + MISTRAL_MODEL: ${{ vars.MISTRAL_PR_REVIEW_MODEL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPOSITORY: ${{ github.repository }} + run: bun run scripts/mistral-pr-review.ts diff --git a/package.json b/package.json index cde4f9d..434600d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stock-exchange-app", - "version": "0.56.0", + "version": "0.57.0", "private": true, "scripts": { "dev": "bun --bun next dev", diff --git a/scripts/mistral-pr-review.ts b/scripts/mistral-pr-review.ts new file mode 100644 index 0000000..868fb48 --- /dev/null +++ b/scripts/mistral-pr-review.ts @@ -0,0 +1,164 @@ +/** + * CI helper: reads BASE_SHA..HEAD_SHA diff, asks Mistral for a review, posts or updates one PR comment. + * Skips cleanly when MISTRAL_API_KEY is unset. + */ +import { spawnSync } from "node:child_process"; + +const MARKER = ""; +const MAX_DIFF_CHARS = 120_000; + +function required(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing env ${name}`); + return v; +} + +function gitDiff(base: string, head: string): string { + const result = spawnSync("git", ["diff", `${base}...${head}`], { + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `git diff failed: ${(result.stderr ?? "").trim() || result.status}` + ); + } + const out = result.stdout ?? ""; + return out.length > MAX_DIFF_CHARS + ? `${out.slice(0, MAX_DIFF_CHARS)}\n\n…(diff truncated after ${MAX_DIFF_CHARS} chars)` + : out; +} + +async function mistralReview( + diff: string, + apiKey: string, + model: string +): Promise { + const system = `You are a senior engineer reviewing a pull request. Be concise and actionable. +Focus on: correctness, edge cases, security, performance, and maintainability. +Use markdown with short sections. Do not repeat the entire diff. If the change looks fine, say so briefly.`; + + const res = await fetch("https://api.mistral.ai/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + temperature: 0.2, + max_tokens: 4096, + messages: [ + { role: "system", content: system }, + { + role: "user", + content: `Review this unified git diff (PR).\n\n${diff}`, + }, + ], + }), + }); + + if (!res.ok) { + const t = await res.text(); + throw new Error(`Mistral API ${res.status}: ${t.slice(0, 500)}`); + } + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const text = data.choices?.[0]?.message?.content?.trim(); + if (!text) throw new Error("Mistral returned empty content"); + return text; +} + +async function githubJson( + token: string, + path: string, + init?: RequestInit +): Promise { + const res = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + Authorization: `Bearer ${token}`, + ...(init?.headers as Record), + }, + }); + const text = await res.text(); + if (!res.ok) + throw new Error(`GitHub API ${res.status} ${path}: ${text.slice(0, 400)}`); + return text ? JSON.parse(text) : null; +} + +async function upsertComment( + token: string, + owner: string, + repo: string, + issueNumber: number, + body: string +): Promise { + const comments = (await githubJson( + token, + `/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=100` + )) as Array<{ id: number; body?: string }>; + + const existing = comments.find((c) => c.body?.includes(MARKER)); + const fullBody = `${MARKER}\n## Mistral code review\n\n${body}`; + + if (existing) { + await githubJson( + token, + `/repos/${owner}/${repo}/issues/comments/${existing.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: fullBody }), + } + ); + return; + } + + await githubJson( + token, + `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: fullBody }), + } + ); +} + +async function main(): Promise { + const apiKey = process.env.MISTRAL_API_KEY?.trim(); + if (!apiKey) { + console.log("MISTRAL_API_KEY not set; skipping Mistral PR review."); + return; + } + + const token = required("GITHUB_TOKEN"); + const repository = required("REPOSITORY"); + const prNumber = Number(required("PR_NUMBER")); + const baseSha = required("BASE_SHA"); + const headSha = required("HEAD_SHA"); + const model = (process.env.MISTRAL_MODEL || "codestral-latest").trim(); + + const [owner, repo] = repository.split("/"); + if (!owner || !repo) throw new Error(`Invalid REPOSITORY: ${repository}`); + + const diff = gitDiff(baseSha, headSha); + if (!diff.trim()) { + console.log("Empty diff; skipping."); + return; + } + + const review = await mistralReview(diff, apiKey, model); + await upsertComment(token, owner, repo, prNumber, review); + console.log("Mistral PR review comment posted."); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});