From e3a1d99437884970a6d3f924527cfeff7cbaff55 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:26:10 +0000 Subject: [PATCH] Add Langfuse tracing support for cursor provider Co-authored-by: benlangfeld <210221+benlangfeld@users.noreply.github.com> --- agentic-pr-review/README.md | 14 + agentic-pr-review/action.yml | 15 + .../config/langfuse-hooks/hooks.json | 65 ++++ .../langfuse-hooks/hooks/hook-handler.js | 70 ++++ .../langfuse-hooks/hooks/lib/handlers.js | 302 ++++++++++++++++++ .../hooks/lib/langfuse-client.js | 130 ++++++++ .../config/langfuse-hooks/hooks/lib/utils.js | 222 +++++++++++++ .../langfuse-hooks/hooks/package-lock.json | 61 ++++ .../config/langfuse-hooks/hooks/package.json | 14 + agentic-pr-review/scripts/providers/cursor.sh | 42 +++ 10 files changed, 935 insertions(+) create mode 100644 agentic-pr-review/config/langfuse-hooks/hooks.json create mode 100644 agentic-pr-review/config/langfuse-hooks/hooks/hook-handler.js create mode 100644 agentic-pr-review/config/langfuse-hooks/hooks/lib/handlers.js create mode 100644 agentic-pr-review/config/langfuse-hooks/hooks/lib/langfuse-client.js create mode 100644 agentic-pr-review/config/langfuse-hooks/hooks/lib/utils.js create mode 100644 agentic-pr-review/config/langfuse-hooks/hooks/package-lock.json create mode 100644 agentic-pr-review/config/langfuse-hooks/hooks/package.json diff --git a/agentic-pr-review/README.md b/agentic-pr-review/README.md index ab67dcc..78b2625 100644 --- a/agentic-pr-review/README.md +++ b/agentic-pr-review/README.md @@ -23,10 +23,14 @@ Artifacts: uploads `review-agent.json` from the workspace when present (for debu | `provider` | no | Review backend; the action resolves it via `scripts/providers/.sh` (default: `cursor`). | | `deepen-length` | no | Passed to `rmacklin/fetch-through-merge-base` as `deepen_length` (default: `30`). | | `additional-prompt` | no | Extra text appended to the review prompt after `prompts/review.md`. | +| `langfuse-secret-key` | no | Langfuse secret key; set with `langfuse-public-key` to enable tracing. | +| `langfuse-public-key` | no | Langfuse public key; required when tracing is enabled. | +| `langfuse-base-url` | no | Langfuse base URL (defaults to `https://cloud.langfuse.com`). | ## Secrets and permissions - Store **`app-id`**, **`private-key`**, and **`provider-api-key`** as repository (or org) secrets; do not commit them. +- If enabling Langfuse tracing, also store **`langfuse-secret-key`** and **`langfuse-public-key`** as secrets. - The calling workflow needs permission to **read** contents and **write** pull requests (for posting the review). The GitHub App installation must be allowed to clone the repo and create reviews on the target repository. ## Example Usage @@ -98,3 +102,13 @@ If your workflow should avoid reviewing draft or closed pull requests, add a sma mkdir -p .cursor cp path/to/agentic-pr-review/config/cli-config.json .cursor/cli-config.json ``` + +## Langfuse tracing (Cursor) + +Provide `langfuse-secret-key` and `langfuse-public-key` inputs to trace Cursor agent activity to Langfuse. When both are present, the action: + +- Copies the bundled Cursor hooks from `config/langfuse-hooks` into `.cursor/` +- Installs hook dependencies with `npm ci` before invoking the Cursor CLI +- Exports `LANGFUSE_*` variables (optional `langfuse-base-url` overrides the default `https://cloud.langfuse.com`) + +If the keys are omitted, Langfuse tracing is skipped and the review runs as before. diff --git a/agentic-pr-review/action.yml b/agentic-pr-review/action.yml index a1416f6..3b23741 100644 --- a/agentic-pr-review/action.yml +++ b/agentic-pr-review/action.yml @@ -30,6 +30,18 @@ inputs: description: Extra instructions appended to the review prompt (e.g. full text of an @nitro-pr-review PR comment) required: false default: "" + langfuse-secret-key: + description: Langfuse secret key used to enable Cursor tracing + required: false + default: "" + langfuse-public-key: + description: Langfuse public key used to enable Cursor tracing + required: false + default: "" + langfuse-base-url: + description: Langfuse base URL (defaults to https://cloud.langfuse.com) + required: false + default: "" runs: using: composite @@ -111,6 +123,9 @@ runs: REVIEW_JSON_PATH: ${{ github.workspace }}/review-agent.json REVIEW_PROMPT_PATH: ${{ github.action_path }}/prompts/review.md REVIEW_ADDITIONAL_INSTRUCTIONS: ${{ inputs.additional-prompt }} + LANGFUSE_SECRET_KEY: ${{ inputs.langfuse-secret-key }} + LANGFUSE_PUBLIC_KEY: ${{ inputs.langfuse-public-key }} + LANGFUSE_BASE_URL: ${{ inputs.langfuse-base-url }} run: | set -euo pipefail diff --git a/agentic-pr-review/config/langfuse-hooks/hooks.json b/agentic-pr-review/config/langfuse-hooks/hooks.json new file mode 100644 index 0000000..418f17d --- /dev/null +++ b/agentic-pr-review/config/langfuse-hooks/hooks.json @@ -0,0 +1,65 @@ +{ + "version": 1, + "hooks": { + "beforeSubmitPrompt": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "afterAgentResponse": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "afterAgentThought": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "beforeShellExecution": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "afterShellExecution": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "beforeMCPExecution": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "afterMCPExecution": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "beforeReadFile": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "afterFileEdit": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "stop": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "beforeTabFileRead": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ], + "afterTabFileEdit": [ + { + "command": "node .cursor/hooks/hook-handler.js" + } + ] + } +} diff --git a/agentic-pr-review/config/langfuse-hooks/hooks/hook-handler.js b/agentic-pr-review/config/langfuse-hooks/hooks/hook-handler.js new file mode 100644 index 0000000..77610d2 --- /dev/null +++ b/agentic-pr-review/config/langfuse-hooks/hooks/hook-handler.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +/** + * Cursor Hooks Langfuse Integration + * + * Main entry point for Cursor hooks that sends traces to Langfuse. + * + * Features: + * - All 12 Cursor hooks supported (Agent + Tab) + * - Traces grouped by conversation_id + * - Sessions grouped by workspace + * - Dynamic tags based on activity + * - Completion scores and efficiency metrics + * - Rich metadata and edit statistics + * + * @version 1.1.0 + * @see https://cursor.com/docs/agent/hooks + * @see https://langfuse.com/docs + */ + +import { readStdin } from "./lib/utils.js"; +import { + getOrCreateTrace, + flushLangfuse, + HOOK_HANDLER_VERSION, +} from "./lib/langfuse-client.js"; +import { routeHookHandler } from "./lib/handlers.js"; + +/** + * Main handler function + * Reads hook data from stdin, creates Langfuse trace, and routes to handler + */ +async function main() { + try { + // Read JSON input from stdin + const input = await readStdin(); + + // Get or create a trace for this conversation + const trace = getOrCreateTrace(input); + + // Route to the appropriate handler based on hook type + const hookName = input.hook_event_name; + const response = routeHookHandler(hookName, trace, input); + + // Output response to Cursor if handler returned one + if (response !== null && response !== undefined) { + console.log(JSON.stringify(response)); + } + + // Flush all pending events to Langfuse before exiting + await flushLangfuse(); + } catch (error) { + // Log error but don't crash - we don't want to block Cursor + console.error(`[Langfuse Hook v${HOOK_HANDLER_VERSION}] Error: ${error.message}`); + + // Still output a permissive response so Cursor can continue + // This ensures the hook doesn't block operations if something goes wrong + console.log( + JSON.stringify({ + continue: true, + permission: "allow", + }), + ); + + process.exit(1); + } +} + +// Run the main function +main(); diff --git a/agentic-pr-review/config/langfuse-hooks/hooks/lib/handlers.js b/agentic-pr-review/config/langfuse-hooks/hooks/lib/handlers.js new file mode 100644 index 0000000..b1f07a8 --- /dev/null +++ b/agentic-pr-review/config/langfuse-hooks/hooks/lib/handlers.js @@ -0,0 +1,302 @@ +/** + * Hook Handlers Module + * Contains handlers for all Cursor hook events. + */ + +import { + calculateEditStats, + getFileExtension, + formatDuration, + determineLevel, + generateTags, +} from "./utils.js"; +import { addCompletionScores, addTagsToTrace } from "./langfuse-client.js"; + +export function handleBeforeSubmitPrompt(trace, input) { + trace.update({ + name: input.prompt?.substring(0, 100) || "User Prompt", + input: input.prompt, + }); + + const generation = trace.generation({ + name: "User Prompt", + input: input.prompt, + model: input.model, + metadata: { + generation_id: input.generation_id, + attachment_count: input.attachments?.length || 0, + attachments: input.attachments?.map((a) => ({ + type: a.type, + path: a.filePath, + extension: getFileExtension(a.filePath), + })), + }, + }); + + if (input.attachments?.length > 0) { + for (const attachment of input.attachments) { + generation + .span({ + name: `Attachment: ${attachment.type}`, + input: { + type: attachment.type, + filePath: attachment.filePath, + extension: getFileExtension(attachment.filePath), + }, + }) + .end(); + } + } + + return { continue: true }; +} + +export function handleAfterAgentResponse(trace, input) { + const responseLength = input.text?.length || 0; + const lineCount = input.text?.split("\n").length || 0; + + trace.update({ output: input.text }); + + trace.generation({ + name: "Agent Response", + output: input.text, + model: input.model, + metadata: { + generation_id: input.generation_id, + response_length: responseLength, + line_count: lineCount, + }, + }); + + return null; +} + +export function handleAfterAgentThought(trace, input) { + trace + .span({ + name: "Agent Thinking", + input: { type: "thinking" }, + output: input.text, + metadata: { + generation_id: input.generation_id, + duration_ms: input.duration_ms, + duration_formatted: formatDuration(input.duration_ms), + thinking_length: input.text?.length || 0, + }, + }) + .end(); + + addTagsToTrace(trace, generateTags("afterAgentThought", input)); + return null; +} + +export function handleBeforeShellExecution(trace, input) { + trace + .span({ + name: `Shell: ${input.command?.substring(0, 50) || "command"}`, + input: { command: input.command, cwd: input.cwd }, + metadata: { + generation_id: input.generation_id, + command_length: input.command?.length || 0, + }, + }) + .end(); + + addTagsToTrace(trace, generateTags("beforeShellExecution", input)); + return { permission: "allow" }; +} + +export function handleAfterShellExecution(trace, input) { + const outputLower = (input.output || "").toLowerCase(); + const mightHaveFailed = + outputLower.includes("error") || + outputLower.includes("failed") || + outputLower.includes("not found"); + + trace + .span({ + name: `Shell Result: ${input.command?.substring(0, 40) || "command"}`, + input: { command: input.command }, + output: input.output, + level: mightHaveFailed ? "WARNING" : "DEFAULT", + metadata: { + generation_id: input.generation_id, + duration_ms: input.duration, + duration_formatted: formatDuration(input.duration), + output_length: input.output?.length || 0, + might_have_failed: mightHaveFailed, + }, + }) + .end(); + + return null; +} + +export function handleBeforeMCPExecution(trace, input) { + trace + .span({ + name: `MCP: ${input.tool_name || "tool"}`, + input: { + tool_name: input.tool_name, + tool_input: input.tool_input, + server_url: input.url, + server_command: input.command, + }, + metadata: { generation_id: input.generation_id }, + }) + .end(); + + addTagsToTrace(trace, generateTags("beforeMCPExecution", input)); + return { permission: "allow" }; +} + +export function handleAfterMCPExecution(trace, input) { + let resultSize = 0; + try { + resultSize = JSON.stringify(input.result_json).length; + } catch { + resultSize = String(input.result_json).length; + } + + trace + .span({ + name: `MCP Result: ${input.tool_name || "tool"}`, + input: { tool_name: input.tool_name, tool_input: input.tool_input }, + output: input.result_json, + metadata: { + generation_id: input.generation_id, + duration_ms: input.duration, + duration_formatted: formatDuration(input.duration), + result_size: resultSize, + }, + }) + .end(); + + return null; +} + +export function handleBeforeReadFile(trace, input) { + const extension = getFileExtension(input.file_path); + + trace + .span({ + name: `Read: ${input.file_path?.split("/").pop() || "file"}`, + input: { file_path: input.file_path, extension }, + metadata: { generation_id: input.generation_id, file_extension: extension }, + }) + .end(); + + addTagsToTrace(trace, generateTags("beforeReadFile", input)); + return { permission: "allow" }; +} + +export function handleAfterFileEdit(trace, input) { + const extension = getFileExtension(input.file_path); + const editStats = calculateEditStats(input.edits); + const fileName = input.file_path?.split("/").pop() || "file"; + + trace + .span({ + name: `Edit: ${fileName}`, + input: { file_path: input.file_path, extension }, + output: { + edit_count: editStats.editCount, + lines_added: editStats.linesAdded, + lines_removed: editStats.linesRemoved, + net_change: editStats.netChange, + edits: input.edits, + }, + metadata: { generation_id: input.generation_id, file_extension: extension, ...editStats }, + }) + .end(); + + return null; +} + +export function handleStop(trace, input) { + const level = determineLevel(input.status); + + trace.event({ + name: "Agent Stopped", + level, + metadata: { + status: input.status, + loop_count: input.loop_count, + generation_id: input.generation_id, + }, + }); + + addCompletionScores(trace, input); + addTagsToTrace(trace, [`status-${input.status}`]); + + return {}; +} + +export function handleBeforeTabFileRead(trace, input) { + const extension = getFileExtension(input.file_path); + const fileName = input.file_path?.split("/").pop() || "file"; + + trace + .span({ + name: `Tab Read: ${fileName}`, + input: { file_path: input.file_path, extension }, + metadata: { generation_id: input.generation_id, file_extension: extension, source: "tab" }, + }) + .end(); + + return { permission: "allow" }; +} + +export function handleAfterTabFileEdit(trace, input) { + const extension = getFileExtension(input.file_path); + const editStats = calculateEditStats(input.edits); + const fileName = input.file_path?.split("/").pop() || "file"; + + trace + .span({ + name: `Tab Edit: ${fileName}`, + input: { file_path: input.file_path, extension }, + output: { + edit_count: editStats.editCount, + edits: input.edits?.map((e) => ({ + range: e.range, + old_line: e.old_line, + new_line: e.new_line, + })), + }, + metadata: { + generation_id: input.generation_id, + file_extension: extension, + source: "tab", + ...editStats, + }, + }) + .end(); + + return null; +} + +export function routeHookHandler(hookName, trace, input) { + const handlers = { + beforeSubmitPrompt: handleBeforeSubmitPrompt, + afterAgentResponse: handleAfterAgentResponse, + afterAgentThought: handleAfterAgentThought, + beforeShellExecution: handleBeforeShellExecution, + afterShellExecution: handleAfterShellExecution, + beforeMCPExecution: handleBeforeMCPExecution, + afterMCPExecution: handleAfterMCPExecution, + beforeReadFile: handleBeforeReadFile, + afterFileEdit: handleAfterFileEdit, + stop: handleStop, + beforeTabFileRead: handleBeforeTabFileRead, + afterTabFileEdit: handleAfterTabFileEdit, + }; + + const handler = handlers[hookName]; + if (!handler) { + console.error(`Unknown hook type: ${hookName}`); + return null; + } + + return handler(trace, input); +} diff --git a/agentic-pr-review/config/langfuse-hooks/hooks/lib/langfuse-client.js b/agentic-pr-review/config/langfuse-hooks/hooks/lib/langfuse-client.js new file mode 100644 index 0000000..7b4807f --- /dev/null +++ b/agentic-pr-review/config/langfuse-hooks/hooks/lib/langfuse-client.js @@ -0,0 +1,130 @@ +/** + * Langfuse Client Module + * + * Handles Langfuse SDK initialization and trace management + * with support for sessions, scoring, and dynamic metadata. + */ + +import { Langfuse } from "langfuse"; +import { config } from "dotenv"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { generateTraceName, generateSessionId, generateTags } from "./utils.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Load .env from project root (3 levels up from lib/) +const projectRoot = resolve(__dirname, "..", "..", ".."); +config({ path: resolve(projectRoot, ".env") }); + +// Fallback: try CWD if keys not found +if (!process.env.LANGFUSE_SECRET_KEY) { + config({ path: resolve(process.cwd(), ".env") }); +} + +export const HOOK_HANDLER_VERSION = "1.2.0"; + +let langfuseInstance = null; + +export function getLangfuseClient() { + if (!langfuseInstance) { + langfuseInstance = new Langfuse({ + secretKey: process.env.LANGFUSE_SECRET_KEY, + publicKey: process.env.LANGFUSE_PUBLIC_KEY, + baseUrl: process.env.LANGFUSE_BASE_URL || "https://cloud.langfuse.com", + release: HOOK_HANDLER_VERSION, + }); + } + return langfuseInstance; +} + +export function getOrCreateTrace(input, customName = null) { + const langfuse = getLangfuseClient(); + const sessionId = generateSessionId(input.workspace_roots); + const traceName = + customName || + generateTraceName(input.prompt, input.model) || + `${input.hook_event_name || "Cursor"} - ${input.model || "Agent"}`; + const tags = generateTags(input.hook_event_name, input); + + return langfuse.trace({ + id: input.conversation_id, + name: traceName, + sessionId: sessionId, + userId: input.user_email || undefined, + release: HOOK_HANDLER_VERSION, + version: input.cursor_version, + metadata: { + cursor_version: input.cursor_version, + model: input.model, + workspace_roots: input.workspace_roots, + generation_id: input.generation_id, + }, + tags: tags, + }); +} + +export function addTagsToTrace(trace, newTags) { + if (trace && newTags && newTags.length > 0) { + trace.update({ tags: newTags }); + } +} + +export function addScore( + trace, + name, + value, + comment = null, + dataType = "NUMERIC", +) { + if (trace) { + trace.score({ name, value, comment, dataType }); + } +} + +export function addCompletionScores(trace, input) { + let statusScore = 0; + let statusComment = ""; + + switch (input.status) { + case "completed": + statusScore = 1; + statusComment = "Agent completed successfully"; + break; + case "aborted": + statusScore = 0.5; + statusComment = "Agent was aborted by user"; + break; + case "error": + statusScore = 0; + statusComment = "Agent encountered an error"; + break; + default: + statusScore = 0.5; + statusComment = `Unknown status: ${input.status}`; + } + + addScore(trace, "completion_status", statusScore, statusComment); + + // Efficiency score: fewer loops = higher score (10+ loops = 0) + if (typeof input.loop_count === "number") { + const efficiencyScore = Math.max(0, 1 - input.loop_count / 10); + addScore( + trace, + "efficiency", + efficiencyScore, + `Completed in ${input.loop_count} loops`, + ); + } +} + +export async function flushLangfuse() { + const langfuse = getLangfuseClient(); + await langfuse.flushAsync(); +} + +export async function shutdownLangfuse() { + const langfuse = getLangfuseClient(); + await langfuse.shutdownAsync(); +} diff --git a/agentic-pr-review/config/langfuse-hooks/hooks/lib/utils.js b/agentic-pr-review/config/langfuse-hooks/hooks/lib/utils.js new file mode 100644 index 0000000..a7352a9 --- /dev/null +++ b/agentic-pr-review/config/langfuse-hooks/hooks/lib/utils.js @@ -0,0 +1,222 @@ +/** + * Utility functions for Cursor Langfuse hooks + */ + +/** + * Read and parse JSON input from stdin + * Cursor hooks pass data via stdin as JSON + * @returns {Promise} Parsed JSON object from stdin + */ +export async function readStdin() { + return new Promise((resolve, reject) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(new Error(`Failed to parse JSON from stdin: ${e.message}`)); + } + }); + process.stdin.on("error", reject); + }); +} + +/** + * Generate a descriptive trace name from the prompt + * @param {string} prompt - The user's prompt text + * @param {string} model - The model being used + * @returns {string} A descriptive trace name + */ +export function generateTraceName(prompt, model) { + if (!prompt) { + return `Cursor ${model || "Agent"}`; + } + + // Extract first meaningful words from the prompt (max 50 chars) + const cleaned = prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(); + + const maxLength = 50; + if (cleaned.length <= maxLength) { + return cleaned; + } + + // Try to cut at a word boundary + const truncated = cleaned.substring(0, maxLength); + const lastSpace = truncated.lastIndexOf(" "); + + if (lastSpace > 30) { + return truncated.substring(0, lastSpace) + "..."; + } + + return truncated + "..."; +} + +/** + * Generate a session ID from workspace roots + * Groups all conversations in the same workspace together + * @param {string[]} workspaceRoots - Array of workspace root paths + * @returns {string} Session ID + */ +export function generateSessionId(workspaceRoots) { + if (!workspaceRoots || workspaceRoots.length === 0) { + return "cursor-default-session"; + } + + // Use the first workspace root as the session identifier + // Extract just the folder name for cleaner session names + const root = workspaceRoots[0]; + const folderName = root.split("/").pop() || root; + + return `cursor-${folderName}`; +} + +/** + * Generate dynamic tags based on hook activity + * @param {string} hookName - The name of the hook being executed + * @param {object} input - The input data from the hook + * @param {Set} existingTags - Set of existing tags to add to + * @returns {string[]} Array of tags + */ +export function generateTags(hookName, input, existingTags = new Set()) { + const tags = new Set(existingTags); + + // Always add cursor tag + tags.add("cursor"); + + // Add agent or tab tag based on hook type + if (hookName.includes("Tab")) { + tags.add("tab"); + } else { + tags.add("agent"); + } + + // Add model-specific tag + if (input.model) { + // Normalize model name to a tag-friendly format + const modelTag = input.model + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .substring(0, 30); + tags.add(modelTag); + } + + // Add hook-type specific tags + switch (hookName) { + case "beforeShellExecution": + case "afterShellExecution": + tags.add("shell"); + break; + case "beforeMCPExecution": + case "afterMCPExecution": + tags.add("mcp"); + if (input.tool_name) { + tags.add(`mcp-${input.tool_name.toLowerCase().substring(0, 20)}`); + } + break; + case "beforeReadFile": + case "afterFileEdit": + case "beforeTabFileRead": + case "afterTabFileEdit": + tags.add("file-ops"); + break; + case "afterAgentThought": + tags.add("thinking"); + break; + } + + return Array.from(tags); +} + +/** + * Determine the observation level based on status or context + * @param {string} status - The status (e.g., 'completed', 'error', 'aborted') + * @param {boolean} isBlocked - Whether the operation was blocked + * @returns {string} Level: 'DEBUG' | 'DEFAULT' | 'WARNING' | 'ERROR' + */ +export function determineLevel(status, isBlocked = false) { + if (isBlocked) { + return "WARNING"; + } + + switch (status) { + case "error": + return "ERROR"; + case "aborted": + return "WARNING"; + case "completed": + default: + return "DEFAULT"; + } +} + +/** + * Calculate edit statistics from an array of edits + * @param {Array<{old_string: string, new_string: string}>} edits - Array of edits + * @returns {object} Edit statistics + */ +export function calculateEditStats(edits) { + if (!edits || !Array.isArray(edits)) { + return { editCount: 0, linesAdded: 0, linesRemoved: 0 }; + } + + let linesAdded = 0; + let linesRemoved = 0; + + for (const edit of edits) { + const oldLines = (edit.old_string || "").split("\n").length; + const newLines = (edit.new_string || "").split("\n").length; + + if (newLines > oldLines) { + linesAdded += newLines - oldLines; + } else if (oldLines > newLines) { + linesRemoved += oldLines - newLines; + } + } + + return { + editCount: edits.length, + linesAdded, + linesRemoved, + netChange: linesAdded - linesRemoved, + }; +} + +/** + * Extract file extension from a file path + * @param {string} filePath - The file path + * @returns {string} The file extension (without dot) or "unknown" + */ +export function getFileExtension(filePath) { + if (!filePath) return "unknown"; + + const parts = filePath.split("."); + if (parts.length < 2) return "unknown"; + + return parts.pop().toLowerCase(); +} + +/** + * Format duration in milliseconds to a human-readable string + * @param {number} ms - Duration in milliseconds + * @returns {string} Formatted duration + */ +export function formatDuration(ms) { + if (!ms || ms < 0) return "0ms"; + + if (ms < 1000) { + return `${ms}ms`; + } + + if (ms < 60000) { + return `${(ms / 1000).toFixed(1)}s`; + } + + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(0); + return `${minutes}m ${seconds}s`; +} diff --git a/agentic-pr-review/config/langfuse-hooks/hooks/package-lock.json b/agentic-pr-review/config/langfuse-hooks/hooks/package-lock.json new file mode 100644 index 0000000..8a26915 --- /dev/null +++ b/agentic-pr-review/config/langfuse-hooks/hooks/package-lock.json @@ -0,0 +1,61 @@ +{ + "name": "cursor-langfuse-hooks", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cursor-langfuse-hooks", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.4.5", + "langfuse": "^3.30.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/langfuse": { + "version": "3.38.6", + "resolved": "https://registry.npmjs.org/langfuse/-/langfuse-3.38.6.tgz", + "integrity": "sha512-mtwfsNGIYvObRh+NYNGlJQJDiBN+Wr3Hnr++wN25mxuOpSTdXX+JQqVCyAqGL5GD2TAXRZ7COsN42Vmp9krYmg==", + "license": "MIT", + "dependencies": { + "langfuse-core": "^3.38.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/langfuse-core": { + "version": "3.38.6", + "resolved": "https://registry.npmjs.org/langfuse-core/-/langfuse-core-3.38.6.tgz", + "integrity": "sha512-EcZXa+DK9FJdi1I30+u19eKjuBJ04du6j2Nybk19KKCuraLczg/ppkTQcGvc4QOk//OAi3qUHrajUuV74RXsBQ==", + "license": "MIT", + "dependencies": { + "mustache": "^4.2.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + } + } +} diff --git a/agentic-pr-review/config/langfuse-hooks/hooks/package.json b/agentic-pr-review/config/langfuse-hooks/hooks/package.json new file mode 100644 index 0000000..39bae11 --- /dev/null +++ b/agentic-pr-review/config/langfuse-hooks/hooks/package.json @@ -0,0 +1,14 @@ +{ + "name": "cursor-langfuse-hooks", + "version": "1.0.0", + "description": "Cursor hooks integration with Langfuse for observability", + "type": "module", + "main": "hook-handler.js", + "scripts": { + "test": "node hook-handler.js" + }, + "dependencies": { + "dotenv": "^16.4.5", + "langfuse": "^3.30.0" + } +} diff --git a/agentic-pr-review/scripts/providers/cursor.sh b/agentic-pr-review/scripts/providers/cursor.sh index f571312..8bf23ef 100755 --- a/agentic-pr-review/scripts/providers/cursor.sh +++ b/agentic-pr-review/scripts/providers/cursor.sh @@ -35,12 +35,54 @@ fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ACTION_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" CLI_CONFIG_TEMPLATE="${ACTION_ROOT}/config/cli-config.json" +LANGFUSE_HOOKS_ROOT="${ACTION_ROOT}/config/langfuse-hooks" + +setup_langfuse_hooks() { + local destination="${GITHUB_WORKSPACE}/.cursor" + local source="${LANGFUSE_HOOKS_ROOT}" + + if ! command -v npm >/dev/null 2>&1; then + echo "npm is required to install Langfuse hook dependencies" >&2 + exit 1 + fi + + if [[ ! -f "${source}/hooks.json" ]]; then + echo "Langfuse hooks configuration not found at ${source}/hooks.json" >&2 + exit 1 + fi + + mkdir -p "${destination}" + cp "${source}/hooks.json" "${destination}/hooks.json" + rm -rf "${destination}/hooks" + cp -R "${source}/hooks" "${destination}/hooks" + + pushd "${destination}/hooks" >/dev/null + npm ci --ignore-scripts --no-audit --no-fund --prefer-offline --no-progress + popd >/dev/null +} cd "${GITHUB_WORKSPACE}" mkdir -p .cursor cp "${CLI_CONFIG_TEMPLATE}" .cursor/cli-config.json +LANGFUSE_ENABLED=false +if [[ -n "${LANGFUSE_SECRET_KEY:-}" || -n "${LANGFUSE_PUBLIC_KEY:-}" || -n "${LANGFUSE_BASE_URL:-}" ]]; then + if [[ -z "${LANGFUSE_SECRET_KEY:-}" || -z "${LANGFUSE_PUBLIC_KEY:-}" ]]; then + echo "Langfuse tracing skipped: LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY are both required" >&2 + else + LANGFUSE_ENABLED=true + fi +fi + +if [[ "${LANGFUSE_ENABLED}" == "true" ]]; then + export LANGFUSE_SECRET_KEY LANGFUSE_PUBLIC_KEY + if [[ -n "${LANGFUSE_BASE_URL:-}" ]]; then + export LANGFUSE_BASE_URL + fi + setup_langfuse_hooks +fi + PROMPT="$(cat "${REVIEW_PROMPT_PATH}")" if [[ -n "${REVIEW_ADDITIONAL_INSTRUCTIONS:-}" ]]; then PROMPT+=$'\n\n## Additional instructions from the PR comment\n\n'"${REVIEW_ADDITIONAL_INSTRUCTIONS}"