Skip to main content

Tutorial: Build a Code Review Helper

In this tutorial, you will build an agent that watches for code.selection events from GitHub pull requests and provides automated code review feedback. When you select code on a GitHub PR page, the agent analyzes it and gives you review comments.

What You Will Build

A code review agent that:

  • Subscribes to user-contexts and filters for code.selection events
  • Detects GitHub PR URLs and extracts the repository, PR number, and file path
  • Extracts the programming language, line range, and code content from selections
  • Generates code review comments using an LLM
  • Tracks selections across multiple files in the same PR
  • Provides a cumulative review summary on demand

Prerequisites

  • Node.js 20 or higher
  • A Lovelace account with an API token
  • The Periscope browser extension installed and connected
  • An Anthropic API key

Step 1: Project Setup

bash
mkdir code-review-helper
cd code-review-helper
pnpm init
pnpm add @lovelace-ai/periscope-client @anthropic-ai/sdk
pnpm add -D typescript @types/node tsx
mkdir src
touch src/reviewer.ts

Create tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}

Set up .env:

bash
LOVELACE_API_TOKEN=your_lovelace_api_token
ANTHROPIC_API_KEY=your_anthropic_api_key

Step 2: Define Types

Define the data structures for tracking code selections and reviews:

typescript
import Anthropic from "@anthropic-ai/sdk";
import { PeriscopeClient } from "@lovelace-ai/periscope-client";
import * as readline from "node:readline/promises";

interface CodeSelection {
  url: string;
  filePath: string;
  language: string;
  code: string;
  lineStart: number;
  lineEnd: number;
  timestamp: Date;
}

interface ReviewComment {
  selection: CodeSelection;
  review: string;
  timestamp: Date;
}

interface PRContext {
  owner: string;
  repo: string;
  prNumber: number;
  /** Map of file path to list of code selections */
  files: Map<string, CodeSelection[]>;
  reviews: ReviewComment[];
}

interface ContextEvent {
  url: string;
  title: string;
  kind: string;
  timestamp: string;
  privacyLevel: string;
  selection?: string;
  metadata?: {
    language?: string;
    filePath?: string;
    lineStart?: number;
    lineEnd?: number;
  };
}

Step 3: Parse GitHub PR URLs

Create a function that detects whether a URL is a GitHub pull request and extracts the key information:

typescript
interface GitHubPRInfo {
  owner: string;
  repo: string;
  prNumber: number;
  filePath?: string;
}

function parseGitHubPRUrl(url: string): GitHubPRInfo | null {
  // Match patterns:
  //   https://github.com/owner/repo/pull/123
  //   https://github.com/owner/repo/pull/123/files
  //   https://github.com/owner/repo/pull/123/files#diff-abc123
  const prPattern = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/;

  const match = url.match(prPattern);
  if (!match) return null;

  const [, owner, repo, prNumberStr] = match;
  const prNumber = parseInt(prNumberStr, 10);

  // Try to extract file path from the URL fragment or path
  let filePath: string | undefined;
  const filePattern = /\/files#diff-[a-f0-9]+/;
  if (filePattern.test(url)) {
    // File path comes from the metadata, not the URL fragment
    filePath = undefined;
  }

  return { owner, repo, prNumber, filePath };
}

function isGitHubPRUrl(url: string): boolean {
  return parseGitHubPRUrl(url) !== null;
}

Step 4: Extract Code Details from Selection Events

When a code.selection event arrives, extract the language, line range, and code content. The Periscope extension attaches metadata to code selections:

typescript
function extractCodeSelection(event: ContextEvent): CodeSelection | null {
  if (event.kind !== "code.selection") return null;
  if (!event.selection) return null;

  const language = event.metadata?.language ?? detectLanguageFromUrl(event.url);
  const filePath = event.metadata?.filePath ?? "unknown";
  const lineStart = event.metadata?.lineStart ?? 0;
  const lineEnd = event.metadata?.lineEnd ?? 0;

  return {
    url: event.url,
    filePath,
    language,
    code: event.selection,
    lineStart,
    lineEnd,
    timestamp: new Date(event.timestamp),
  };
}

function detectLanguageFromUrl(url: string): string {
  // Fallback: try to detect language from file extension in URL
  const extMatch = url.match(/\.([a-zA-Z0-9]+)(?:#|$|\?)/);
  if (!extMatch) return "unknown";

  const ext = extMatch[1].toLowerCase();
  const languageMap: Record<string, string> = {
    ts: "typescript",
    tsx: "typescript",
    js: "javascript",
    jsx: "javascript",
    py: "python",
    rs: "rust",
    go: "go",
    rb: "ruby",
    java: "java",
    kt: "kotlin",
    swift: "swift",
    cpp: "cpp",
    c: "c",
    cs: "csharp",
    php: "php",
    sql: "sql",
    sh: "bash",
    yml: "yaml",
    yaml: "yaml",
    json: "json",
    md: "markdown",
  };

  return languageMap[ext] ?? ext;
}

Step 5: Build the Review Prompt

Create a focused prompt that asks the LLM to review the selected code. Include the language, file path, and line range for context:

typescript
function buildReviewPrompt(
  selection: CodeSelection,
  prInfo: GitHubPRInfo,
): string {
  return `You are an experienced code reviewer. Review the following code selection from a GitHub pull request.

## PR Context
- Repository: ${prInfo.owner}/${prInfo.repo}
- Pull Request: #${prInfo.prNumber}
- File: ${selection.filePath}
- Lines: ${selection.lineStart}${selection.lineEnd}
- Language: ${selection.language}

## Selected Code

\`\`\`${selection.language}
${selection.code}
\`\`\`

## Review Instructions

Provide a concise code review covering:
1. **Correctness** — Are there bugs or logic errors?
2. **Style** — Does the code follow conventions for ${selection.language}?
3. **Performance** — Are there obvious performance issues?
4. **Security** — Are there security concerns (injection, auth, data exposure)?
5. **Suggestions** — What would you change?

Keep the review focused and actionable. If the code looks good, say so briefly. Do not repeat the code back.`;
}

Step 6: Generate Reviews with the LLM

typescript
async function generateReview(
  anthropic: Anthropic,
  selection: CodeSelection,
  prInfo: GitHubPRInfo,
): Promise<string> {
  const prompt = buildReviewPrompt(selection, prInfo);

  const response = await anthropic.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 800,
    messages: [{ role: "user", content: prompt }],
  });

  return response.content[0].type === "text" ? response.content[0].text : "";
}

Step 7: Track Selections Across PR Files

Maintain a map of active PRs and the files/selections within each:

typescript
const activePRs = new Map<string, PRContext>();

function getPRKey(info: GitHubPRInfo): string {
  return `${info.owner}/${info.repo}#${info.prNumber}`;
}

function getOrCreatePR(info: GitHubPRInfo): PRContext {
  const key = getPRKey(info);
  let pr = activePRs.get(key);

  if (!pr) {
    pr = {
      owner: info.owner,
      repo: info.repo,
      prNumber: info.prNumber,
      files: new Map(),
      reviews: [],
    };
    activePRs.set(key, pr);
  }

  return pr;
}

function addSelectionToPR(pr: PRContext, selection: CodeSelection): void {
  const existing = pr.files.get(selection.filePath);
  if (existing) {
    existing.push(selection);
  } else {
    pr.files.set(selection.filePath, [selection]);
  }
}

async function generatePRSummary(
  anthropic: Anthropic,
  pr: PRContext,
): Promise<string> {
  if (pr.reviews.length === 0) {
    return "No code selections reviewed yet for this PR.";
  }

  const reviewSummary = pr.reviews
    .map(
      (r, i) =>
        `### ${i + 1}. ${r.selection.filePath} (lines ${r.selection.lineStart}${r.selection.lineEnd})\n${r.review}`,
    )
    .join("\n\n");

  const prompt = `You reviewed ${pr.reviews.length} code selections across ${pr.files.size} files in ${pr.owner}/${pr.repo}#${pr.prNumber}. Here are the individual reviews:

${reviewSummary}

Write a brief overall PR review summary (3-5 sentences) highlighting the most important findings and any patterns across files.`;

  const response = await anthropic.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 512,
    messages: [{ role: "user", content: prompt }],
  });

  return response.content[0].type === "text" ? response.content[0].text : "";
}

Step 8: Wire It All Together

Create the main event loop that listens for code selections and generates reviews:

typescript
async function main(): Promise<void> {
  const token = process.env.LOVELACE_API_TOKEN;
  if (!token) throw new Error("LOVELACE_API_TOKEN is required");

  const client = new PeriscopeClient({
    baseUrl: "https://periscope.uselovelace.com",
    wsUrl: "wss://periscope.uselovelace.com/ws",
    token,
  });

  await client.connect();
  console.log("[Connected to Periscope]");

  const anthropic = new Anthropic();

  // Subscribe to context events and filter for code selections on GitHub PRs
  client.subscribe("user-contexts", async (event: ContextEvent) => {
    if (event.privacyLevel === "restricted") return;
    if (event.kind !== "code.selection") return;

    const prInfo = parseGitHubPRUrl(event.url);
    if (!prInfo) return;

    const selection = extractCodeSelection(event);
    if (!selection) return;

    const pr = getOrCreatePR(prInfo);
    addSelectionToPR(pr, selection);

    console.log(
      `\n[Code selected in ${prInfo.owner}/${prInfo.repo}#${prInfo.prNumber}]`,
    );
    console.log(
      `  File: ${selection.filePath} (lines ${selection.lineStart}${selection.lineEnd})`,
    );
    console.log(`  Language: ${selection.language}`);
    console.log(`  Generating review...\n`);

    try {
      const review = await generateReview(anthropic, selection, prInfo);

      pr.reviews.push({
        selection,
        review,
        timestamp: new Date(),
      });

      console.log(`--- Review ---`);
      console.log(review);
      console.log(`--- End Review ---\n`);
    } catch (error) {
      const err = error as { message?: string };
      console.log(`[Review failed: ${err.message}]\n`);
    }
  });

  // Interactive commands
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  console.log("\nCode Review Helper");
  console.log("==================");
  console.log("Select code on GitHub PR pages to get instant reviews.");
  console.log("Commands:");
  console.log(
    '  "summary"  — Generate a summary of all reviews for active PRs',
  );
  console.log('  "files"    — List reviewed files across all PRs');
  console.log('  "clear"    — Clear review history');
  console.log('  "quit"     — Exit\n');

  while (true) {
    const input = await rl.question("> ");
    const trimmed = input.trim().toLowerCase();

    if (trimmed === "quit") break;

    if (trimmed === "summary") {
      if (activePRs.size === 0) {
        console.log("\nNo PRs reviewed yet. Select code on a GitHub PR!\n");
        continue;
      }

      for (const [key, pr] of activePRs) {
        console.log(`\n=== ${key} ===`);
        const summary = await generatePRSummary(anthropic, pr);
        console.log(summary);
      }
      console.log();
      continue;
    }

    if (trimmed === "files") {
      if (activePRs.size === 0) {
        console.log("\nNo files reviewed yet.\n");
        continue;
      }

      for (const [key, pr] of activePRs) {
        console.log(`\n${key}:`);
        for (const [filePath, selections] of pr.files) {
          console.log(`  ${filePath}${selections.length} selection(s)`);
        }
      }
      console.log();
      continue;
    }

    if (trimmed === "clear") {
      activePRs.clear();
      console.log("\nReview history cleared.\n");
      continue;
    }
  }

  client.disconnect();
  rl.close();
  console.log("Goodbye!");
}

main().catch(console.error);

Step 9: Run It

bash
export $(cat .env | xargs)
npx tsx src/reviewer.ts

Complete Code Listing

The full src/reviewer.ts is the combination of all steps above. Copy Steps 2 through 8 into a single file with imports at the top and main() at the bottom.

Example Interaction

Here is what it looks like in practice. You open a GitHub PR, navigate to the Files tab, and select a block of code:

[Connected to Periscope]

Code Review Helper
==================
Select code on GitHub PR pages to get instant reviews.

[Code selected in acme/api-server#42]
  File: src/handlers/auth.ts (lines 15-28)
  Language: typescript
  Generating review...

--- Review ---
**Correctness**: The JWT verification on line 22 catches the error but returns a
generic 500 status. If `jwt.verify()` throws a `TokenExpiredError`, this should
return 401, not 500.

**Security**: The token is extracted from the query string parameter on line 16.
Tokens in query strings get logged in server access logs and browser history.
Extract from the Authorization header instead.

**Suggestion**: Replace the catch-all error handler with typed error checking:
```typescript
if (error instanceof jwt.TokenExpiredError) {
  return res.status(401).json({ error: "token_expired" });
}
if (error instanceof jwt.JsonWebTokenError) {
  return res.status(401).json({ error: "invalid_token" });
}

--- End Review ---

[Code selected in acme/api-server#42] File: src/middleware/rate-limit.ts (lines 5-18) Language: typescript Generating review...

--- Review --- Performance: The rate limit counter uses an in-memory Map (line 7). This will not work in a multi-instance deployment because each server has its own counter. Consider using Redis or a shared cache.

Correctness: The cleanup interval on line 12 runs every 60 seconds but the window is 15 seconds. Expired entries can accumulate between cleanup cycles. Consider using a sliding window algorithm.

Style: Looks good otherwise. Clean separation of concerns. --- End Review ---

summary

=== acme/api-server#42 === Two code selections were reviewed across 2 files. The main concerns are security-related: JWT tokens should not be passed in query strings, and error handling should distinguish between expired and invalid tokens. The rate limiting implementation also needs attention for production use — the in-memory storage will not work across multiple server instances.

quit Goodbye!


## Next Steps

- Extend the agent to also capture `text.selection` events for PR description and review comments
- Add support for GitLab and Bitbucket PR URL patterns
- Read the [Agent Integration Guide](/periscope/guides/agent-integration) for advanced prompt engineering patterns
- Explore the [WebSocket API reference](/periscope/websocket-api) for additional event types