From 8d39588b1ccba92fcc244eebc5288d4a76afcfa4 Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Fri, 19 Jun 2026 00:06:06 +0200 Subject: [PATCH 1/2] refactor: remove migrated git tools and duplicate patch_file #40 --- src/four-opencode-supertools.ts | 44 ---- src/lib/gh-utils.ts | 111 --------- src/lib/git-utils.ts | 314 ------------------------ src/lib/gitlab-utils.ts | 79 ------ src/tools/apply-patch.ts | 105 -------- src/tools/blast-radius.ts | 284 ---------------------- src/tools/bus-factor.ts | 161 ------------- src/tools/curse-score.ts | 191 --------------- src/tools/gh-bot-review.ts | 313 ------------------------ src/tools/gh-branch-cleanup.ts | 175 -------------- src/tools/gh-issue-close.ts | 131 ---------- src/tools/gh-issue-list.ts | 153 ------------ src/tools/gh-pr-comment.ts | 42 ---- src/tools/gh-pr-create.ts | 48 ---- src/tools/gh-pr-review.ts | 104 -------- src/tools/gh-pr-status.ts | 226 ----------------- src/tools/gh-release-info.ts | 146 ----------- src/tools/git-diff.ts | 187 -------------- src/tools/git-log-structured.ts | 203 ---------------- src/tools/gitlab-mr-comment.ts | 42 ---- src/tools/gitlab-mr-create.ts | 47 ---- src/tools/gitlab-mr-status.ts | 67 ------ src/tools/implicit-coupling.ts | 157 ------------ src/tools/ownership.ts | 167 ------------- src/tools/pr-risk.ts | 232 ------------------ src/tools/trend.ts | 211 ---------------- tests/apply-patch.test.ts | 111 --------- tests/blast-radius.test.ts | 274 --------------------- tests/bus-factor.test.ts | 132 ---------- tests/curse-score.test.ts | 156 ------------ tests/gh-bot-review.test.ts | 371 ---------------------------- tests/gh-tools.test.ts | 402 ------------------------------- tests/git-diff.test.ts | 291 ---------------------- tests/git-log-structured.test.ts | 157 ------------ tests/implicit-coupling.test.ts | 148 ------------ tests/ownership.test.ts | 182 -------------- tests/pr-risk.test.ts | 147 ----------- tests/trend.test.ts | 225 ----------------- 38 files changed, 6536 deletions(-) delete mode 100644 src/lib/gh-utils.ts delete mode 100644 src/lib/git-utils.ts delete mode 100644 src/lib/gitlab-utils.ts delete mode 100644 src/tools/apply-patch.ts delete mode 100644 src/tools/blast-radius.ts delete mode 100644 src/tools/bus-factor.ts delete mode 100644 src/tools/curse-score.ts delete mode 100644 src/tools/gh-bot-review.ts delete mode 100644 src/tools/gh-branch-cleanup.ts delete mode 100644 src/tools/gh-issue-close.ts delete mode 100644 src/tools/gh-issue-list.ts delete mode 100644 src/tools/gh-pr-comment.ts delete mode 100644 src/tools/gh-pr-create.ts delete mode 100644 src/tools/gh-pr-review.ts delete mode 100644 src/tools/gh-pr-status.ts delete mode 100644 src/tools/gh-release-info.ts delete mode 100644 src/tools/git-diff.ts delete mode 100644 src/tools/git-log-structured.ts delete mode 100644 src/tools/gitlab-mr-comment.ts delete mode 100644 src/tools/gitlab-mr-create.ts delete mode 100644 src/tools/gitlab-mr-status.ts delete mode 100644 src/tools/implicit-coupling.ts delete mode 100644 src/tools/ownership.ts delete mode 100644 src/tools/pr-risk.ts delete mode 100644 src/tools/trend.ts delete mode 100644 tests/apply-patch.test.ts delete mode 100644 tests/blast-radius.test.ts delete mode 100644 tests/bus-factor.test.ts delete mode 100644 tests/curse-score.test.ts delete mode 100644 tests/gh-bot-review.test.ts delete mode 100644 tests/gh-tools.test.ts delete mode 100644 tests/git-diff.test.ts delete mode 100644 tests/git-log-structured.test.ts delete mode 100644 tests/implicit-coupling.test.ts delete mode 100644 tests/ownership.test.ts delete mode 100644 tests/pr-risk.test.ts delete mode 100644 tests/trend.test.ts diff --git a/src/four-opencode-supertools.ts b/src/four-opencode-supertools.ts index b721c7a..f5c5d6a 100644 --- a/src/four-opencode-supertools.ts +++ b/src/four-opencode-supertools.ts @@ -2,62 +2,18 @@ // Copyright (c) 2025-2026 Four Bytes import type { Plugin } from '@opencode-ai/plugin'; -import { applyPatchTool } from './tools/apply-patch'; import { batchEditTool } from './tools/batch-edit'; import { lintFileTool } from './tools/lint-file'; import { runTestsTool } from './tools/run-tests'; -import { curseScoreTool } from './tools/curse-score'; -import { busFactorTool } from './tools/bus-factor'; -import { implicitCouplingTool } from './tools/implicit-coupling'; -import { ownershipTool } from './tools/ownership'; -import { blastRadiusTool } from './tools/blast-radius'; -import { gitDiffTool } from './tools/git-diff'; -import { trendTool } from './tools/trend'; -import { prRiskTool } from './tools/pr-risk'; -import { ghIssueListTool } from './tools/gh-issue-list'; -import { ghIssueCloseTool } from './tools/gh-issue-close'; -import { ghPrStatusTool } from './tools/gh-pr-status'; -import { ghBranchCleanupTool } from './tools/gh-branch-cleanup'; -import { ghReleaseInfoTool } from './tools/gh-release-info'; -import { gitLogStructuredTool } from './tools/git-log-structured'; -import { gitlabMrCreateTool } from './tools/gitlab-mr-create'; -import { gitlabMrCommentTool } from './tools/gitlab-mr-comment'; -import { gitlabMrStatusTool } from './tools/gitlab-mr-status'; -import { ghPrCreateTool } from './tools/gh-pr-create'; -import { ghPrCommentTool } from './tools/gh-pr-comment'; -import { ghPrReviewTool } from './tools/gh-pr-review'; import { appendFileTool } from './tools/append-file'; -import { ghBotReviewTool } from './tools/gh-bot-review'; const FourOpencodeSupertools: Plugin = async (_ctx) => { return { tool: { - patch_file: applyPatchTool, batch_edit: batchEditTool, lint_file: lintFileTool, run_tests: runTestsTool, - curse_score: curseScoreTool, - bus_factor: busFactorTool, - implicit_coupling: implicitCouplingTool, - ownership: ownershipTool, - blast_radius: blastRadiusTool, - git_diff: gitDiffTool, - trend: trendTool, - pr_risk: prRiskTool, - gh_issue_list: ghIssueListTool, - gh_issue_close: ghIssueCloseTool, - gh_pr_status: ghPrStatusTool, - gh_branch_cleanup: ghBranchCleanupTool, - gh_release_info: ghReleaseInfoTool, - git_log_structured: gitLogStructuredTool, - gitlab_mr_create: gitlabMrCreateTool, - gitlab_mr_comment: gitlabMrCommentTool, - gitlab_mr_status: gitlabMrStatusTool, - gh_pr_create: ghPrCreateTool, - gh_pr_comment: ghPrCommentTool, - gh_pr_review: ghPrReviewTool, append_file: appendFileTool, - gh_bot_review: ghBotReviewTool, }, }; }; diff --git a/src/lib/gh-utils.ts b/src/lib/gh-utils.ts deleted file mode 100644 index 492ebf4..0000000 --- a/src/lib/gh-utils.ts +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -/** - * Shared GitHub CLI utility functions for all gh_* tools. - * Wraps `gh` CLI commands with error handling and repo resolution. - */ - -// ──────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────── - -export interface GhExecResult { - stdout: string; - stderr: string; - exitCode: number; -} - -// ──────────────────────────────────────────────────────────────── -// 1. runGh — execute a gh CLI command via Bun.spawn -// ──────────────────────────────────────────────────────────────── - -/** - * Run a `gh` CLI command and return trimmed stdout. - * Throws on non-zero exit with descriptive error messages. - * Handles gh-not-installed, not-authenticated, and 404 errors gracefully. - */ -export async function runGh(args: string[], cwd: string, _timeout = 30000): Promise { - let proc; - try { - proc = Bun.spawn(['gh', ...args], { - cwd, - stdout: 'pipe', - stderr: 'pipe', - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('No such file') || msg.includes('not found') || msg.includes('ENOENT')) { - throw new Error('GitHub CLI (gh) is not installed. Install from https://cli.github.com/', { - cause: err, - }); - } - throw new Error(`Failed to spawn gh: ${msg}`, { cause: err }); - } - - const exitCode = await proc.exited; - const stderr = await new Response(proc.stderr).text(); - - if (exitCode !== 0) { - const trimmed = stderr.trim(); - if ( - trimmed.includes('To authenticate') || - trimmed.includes('not authenticated') || - trimmed.includes('gh auth login') - ) { - throw new Error('GitHub CLI not authenticated. Run `gh auth login` first.'); - } - // 404 detection via stderr message patterns - if ( - trimmed.includes('Not Found') || - trimmed.includes('404') || - trimmed.includes('could not find') - ) { - throw new Error(`Resource not found: ${trimmed}`); - } - throw new Error(`gh exited with code ${exitCode}: ${trimmed || '(no stderr)'}`); - } - - return (await new Response(proc.stdout).text()).trim(); -} - -// ──────────────────────────────────────────────────────────────── -// 2. resolveRepo — determine the GitHub repo (owner/repo) -// ──────────────────────────────────────────────────────────────── - -/** - * Resolve the current GitHub repository name in owner/repo format. - * Uses `gh repo view --json nameWithOwner` for reliable detection. - * - * @param repo - Explicit repo override (e.g., "owner/repo") - * @param cwd - Working directory - * @returns The repo name in owner/repo format - */ -export async function resolveRepo(repo: string | undefined, cwd: string): Promise { - if (repo) { - // Validate format: must be owner/repo - if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) { - throw new Error( - `Invalid repo format: "${repo}". Expected "owner/repo" (e.g., "four-bytes/four-opencode-supertools").` - ); - } - return repo; - } - - try { - const raw = await runGh( - ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], - cwd - ); - if (!raw.trim()) { - throw new Error('Empty response from gh repo view'); - } - return raw.trim(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error( - `Could not determine current GitHub repo. Ensure you are in a git repo with a GitHub remote. ${msg}`, - { cause: err } - ); - } -} diff --git a/src/lib/git-utils.ts b/src/lib/git-utils.ts deleted file mode 100644 index c9d4b0e..0000000 --- a/src/lib/git-utils.ts +++ /dev/null @@ -1,314 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -/** - * Shared git utility functions for all git-history analytics tools. - * Merges git-runner, git-log-parser, and git-blame-parser into one module. - */ - -// ──────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────── - -export interface FileChange { - path: string; - added: number; - deleted: number; -} - -export interface Commit { - hash: string; - author: string; - date: string; // ISO 8601 - files: FileChange[]; -} - -export interface BlameLine { - line: number; - author: string; - commit: string; -} - -// ──────────────────────────────────────────────────────────────── -// 1a. runGit — execute a git command via Bun.spawn -// ──────────────────────────────────────────────────────────────── - -/** - * Run a git command and return trimmed stdout. - * Throws on non-zero exit with stderr message. - * Handles git-not-installed and not-a-repo errors gracefully. - */ -export async function runGit(args: string[], cwd: string, _timeout = 30000): Promise { - let proc; - try { - proc = Bun.spawn(['git', ...args], { - cwd, - stdout: 'pipe', - stderr: 'pipe', - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('No such file') || msg.includes('not found') || msg.includes('ENOENT')) { - throw new Error('git is not installed or not found in PATH', { cause: err }); - } - throw new Error(`Failed to spawn git: ${msg}`, { cause: err }); - } - - const exitCode = await proc.exited; - const stderr = await new Response(proc.stderr).text(); - - if (exitCode !== 0) { - const trimmed = stderr.trim(); - if (trimmed.includes('not a git repository')) { - throw new Error('Not a git repository (or any parent up to mount point)'); - } - if (trimmed.includes('does not have any commits')) { - throw new Error('Git repository has no commits yet'); - } - throw new Error(`git exited with code ${exitCode}: ${trimmed || '(no stderr)'}`); - } - - return (await new Response(proc.stdout).text()).trim(); -} - -// ──────────────────────────────────────────────────────────────── -// 1b. parseGitLog — parse git log into structured Commits -// ──────────────────────────────────────────────────────────────── - -/** - * Parse `git log` output into structured Commit objects. - * Uses `git log --numstat --format='%H|%an|%aI'` for machine-readable output. - * - * @param cwd — Working directory (repo root) - * @param since Optional date filter (e.g., '90d', '2024-01-01', '6 months ago') - * @param until Optional upper bound date filter (e.g., '90 days ago') - */ -export async function parseGitLog(cwd: string, since?: string, until?: string): Promise { - const args = ['log', '--numstat', '--format=%H|%an|%aI']; - - if (since) { - args.push(`--since=${since}`); - } - if (until) { - args.push(`--until=${until}`); - } - - const output = await runGit(args, cwd); - return parseLogOutput(output); -} - -/** - * Parse the raw output of `git log --numstat --format='%H|%an|%aI'`. - * Exported for testing. - * - * Output format: - * HASH|AUTHOR|DATE - * (blank line — separator between header and numstat) - * added\tdeleted\tpath - * ... - * (blank line before next commit) - * HASH|AUTHOR|DATE - * ... - */ -export function parseLogOutput(raw: string): Commit[] { - const commits: Commit[] = []; - let currentCommit: Commit | null = null; - - const lines = raw.split('\n'); - - for (const line of lines) { - // Check if this is a commit header line: HASH|AUTHOR|DATE - // Hash is exactly 40 hex chars, followed by |author|ISO-date - const headerMatch = line.match(/^([0-9a-f]{40})\|([^|]+)\|(.+)$/); - if (headerMatch) { - // Save previous commit before starting new one - if (currentCommit) { - commits.push(currentCommit); - } - currentCommit = { - hash: headerMatch[1]!, - author: headerMatch[2]!, - date: headerMatch[3]!, - files: [], - }; - continue; - } - - // Skip blank lines (separators) - if (line.trim() === '') { - continue; - } - - // Otherwise it's a numstat file line: added\tdeleted\tpath - if (currentCommit) { - const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/); - if (match) { - const added = match[1] === '-' ? 0 : parseInt(match[1], 10); - const deleted = match[2] === '-' ? 0 : parseInt(match[2], 10); - const path = match[3]!; - currentCommit.files.push({ path, added, deleted }); - } - } - } - - // Don't forget the last commit - if (currentCommit) { - commits.push(currentCommit); - } - - return commits; -} - -// ──────────────────────────────────────────────────────────────── -// 1c. getFileList — list tracked files excluding noise -// ──────────────────────────────────────────────────────────────── - -/** - * Get filtered list of tracked files via `git ls-files`. - * Excludes lockfiles, changelogs, CI configs, dist/, node_modules, minified files. - */ -export async function getFileList(cwd: string): Promise { - const output = await runGit(['ls-files'], cwd); - return output.split('\n').filter((f) => f.trim() !== '' && !isExcluded(f)); -} - -// ──────────────────────────────────────────────────────────────── -// 1d. isExcluded — filter noise files from analysis -// ──────────────────────────────────────────────────────────────── - -/** - * Filter out files that shouldn't be analyzed for ownership/coupling. - * Excludes lockfiles, changelogs, CI configs, dist/, node_modules, minified files, - * and binary files (detected by `-` in added/deleted numstat). - */ -export function isExcluded(file: string): boolean { - // Lockfiles - if (/^(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lock|bun\.lockb)$/.test(file)) { - return true; - } - - // Changelogs - if (/^(CHANGELOG\.md|HISTORY\.md)$/.test(file)) return true; - if (/\.changelog/i.test(file)) return true; - - // CI configs - if (/^\.github\/workflows\/.*\.yml$/.test(file)) return true; - if (file === '.github/dependabot.yml') return true; - - // Generated / vendor directories - if (file.startsWith('dist/') || file.startsWith('node_modules/') || file.startsWith('.git/')) { - return true; - } - - // Minified files - if (/\.min\.(js|css)$/.test(file)) return true; - - return false; -} - -// ──────────────────────────────────────────────────────────────── -// 1e & 1f. parseGitBlame / parseGitBlameForDir -// ──────────────────────────────────────────────────────────────── - -/** - * Parse `git blame --line-porcelain` for a single file. - * Returns an array of BlameLine, one per line of the file. - */ -export async function parseGitBlame(filePath: string, cwd?: string): Promise { - const workDir = cwd ?? process.cwd(); - try { - const output = await runGit(['blame', '--line-porcelain', '--', filePath], workDir); - return parseBlameOutput(output); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - // If the file doesn't exist in git, return empty array - if (msg.includes('no such path') || msg.includes('exists on disk, but not in')) { - return []; - } - throw err; - } -} - -/** - * Parse `git blame --line-porcelain` for all tracked files in a directory. - * Returns a Map of file path → blame lines. - */ -export async function parseGitBlameForDir( - dirPath: string, - cwd?: string -): Promise> { - const workDir = cwd ?? process.cwd(); - const result = new Map(); - - // Get all tracked files in the directory - let fileList: string; - try { - fileList = await runGit(['ls-files', '--', dirPath], workDir); - } catch { - return result; - } - - const files = fileList.split('\n').filter((f) => f.trim() !== ''); - - for (const file of files) { - const blame = await parseGitBlame(file, workDir); - if (blame.length > 0) { - result.set(file, blame); - } - } - - return result; -} - -/** - * Parse the raw output of `git blame --line-porcelain`. - * The porcelain format emits: - * - A header line per commit: COMMIT_HASH ORIG_LINE FINAL_LINE [GROUP_SIZE] - * - Then "pseudo-headers" prefixed with a space and field name - * - Then the actual line content prefixed with a tab - * - * Exported for testing. - */ -export function parseBlameOutput(raw: string): BlameLine[] { - const lines: BlameLine[] = []; - const allLines = raw.split('\n'); - - let currentLineNum = 0; - let currentCommit = ''; - let currentAuthor = ''; - - for (const line of allLines) { - // Header line: <40-char-hex> [group-size] - const headerMatch = line.match(/^([0-9a-f]{40})\s+(\d+)\s+(\d+)(?:\s+(\d+))?$/); - if (headerMatch) { - currentCommit = headerMatch[1]!; - currentLineNum = parseInt(headerMatch[3]!, 10); - // Reset for new entry - currentAuthor = ''; - continue; - } - - // Pseudo-header: space-prefixed field - if (line.startsWith('author ')) { - currentAuthor = line.slice('author '.length); - continue; - } - - // Tab-prefixed line is the actual file content - if (line.startsWith('\t')) { - if (currentLineNum > 0) { - lines.push({ - line: currentLineNum, - author: currentAuthor, - commit: currentCommit, - }); - currentLineNum++; // increment for group lines - } - continue; - } - - // Other pseudo-headers are ignored - } - - return lines; -} diff --git a/src/lib/gitlab-utils.ts b/src/lib/gitlab-utils.ts deleted file mode 100644 index 4fa6257..0000000 --- a/src/lib/gitlab-utils.ts +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { logDebugEvent } from './debug-logger'; - -export interface GitLabConfig { - token: string; - host: string; -} - -/** Read GitLab config from environment */ -export function getGitLabConfig(): GitLabConfig | null { - const token = process.env.GITLAB_TOKEN; - if (!token) { - logDebugEvent('gitlab.config.missing', { reason: 'GITLAB_TOKEN not set' }); - return null; - } - return { - token, - host: process.env.GITLAB_HOST || 'https://gitlab.com', - }; -} - -/** Get project ID from current repo's remote origin */ -export async function getGitLabProjectId(cwd: string): Promise { - try { - const proc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], { cwd, stdout: 'pipe' }); - const url = (await new Response(proc.stdout).text()).trim(); - // Extract: :group/project.git → group/project - const match = url.match(/[/:]([^/]+\/[^.]+?)(?:\.git)?$/); - if (match) { - return encodeURIComponent(match[1]); - } - return null; - } catch { - return null; - } -} - -/** Call GitLab API */ -export async function gitlabApi( - path: string, - method: 'GET' | 'POST' | 'PUT' = 'GET', - body?: object -): Promise<{ ok: boolean; status: number; data: any; error?: string }> { - const cfg = getGitLabConfig(); - if (!cfg) return { ok: false, status: 0, data: null, error: 'GITLAB_TOKEN not set' }; - - const url = `${cfg.host}/api/v4/${path}`; - - try { - const opts: RequestInit = { - method, - headers: { - 'PRIVATE-TOKEN': cfg.token, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - }; - if (body) opts.body = JSON.stringify(body); - - const response = await fetch(url, opts); - const data = await response.json().catch(() => null); - - return { - ok: response.ok, - status: response.status, - data, - error: response.ok ? undefined : (data as any)?.message || `HTTP ${response.status}`, - }; - } catch (err) { - return { - ok: false, - status: 0, - data: null, - error: err instanceof Error ? err.message : String(err), - }; - } -} diff --git a/src/tools/apply-patch.ts b/src/tools/apply-patch.ts deleted file mode 100644 index 17480ae..0000000 --- a/src/tools/apply-patch.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { tool } from '@opencode-ai/plugin'; -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { parseUnifiedDiff, validateHunks } from '../lib/diff-parse'; -import { applyHunks, summarizeChanges } from '../lib/diff-apply'; -import { logDebugEvent } from '../lib/debug-logger'; - -export const applyPatchTool = tool({ - description: `Apply a unified diff patch to a file. Use this for ALL file modifications (add, update, delete) to save tokens compared to full-file write. - -The patch must be in standard unified diff format (same as \`diff -u\` output): - @@ -10,5 +10,7 @@ - unchanged context line - -removed line - +added line - unchanged context line - -For new files, use a patch that adds all content: - @@ -0,0 +1,3 @@ - +line 1 - +line 2 - +line 3 - -IMPORTANT: Always use this tool instead of \`write\` or \`edit\` when modifying existing files. It saves ~90% output tokens.`, - - args: { - file_path: tool.schema.string().describe('Absolute path to the file to patch'), - patch: tool.schema - .string() - .describe( - 'Unified diff patch to apply. Must include proper @@ hunk headers with line numbers.' - ), - }, - - async execute(args, _ctx) { - const { file_path, patch } = args; - - if (!file_path || typeof file_path !== 'string') { - return 'Error: Missing required parameter "file_path". Provide the absolute path to the file to patch.'; - } - if (!patch || typeof patch !== 'string') { - return 'Error: Missing required parameter "patch". Provide a unified diff patch.'; - } - - logDebugEvent('patch_file.start', { file_path, patchLength: patch.length }); - - try { - // 1. Parse the diff - const parsed = parseUnifiedDiff(patch); - - if (parsed.hunks.length === 0) { - logDebugEvent('patch_file.no_hunks', { file_path }); - return 'Error: Could not parse any hunks from the patch. Ensure the patch uses standard unified diff format with @@ headers.'; - } - - // 2. Handle new file creation - if (!existsSync(file_path)) { - // Check if this looks like a new file patch (all hunks start at 0,0) - const isNewFile = parsed.hunks.every((h) => h.oldStart === 0 && h.oldLines === 0); - if (isNewFile) { - // For new files, all content comes from '+' lines - const newContent = applyHunks(parsed.hunks, ''); - writeFileSync(file_path, newContent, 'utf-8'); - - const { added } = summarizeChanges(parsed.hunks); - logDebugEvent('patch_file.new_file', { file_path, added }); - return `Created new file with ${added} lines: ${file_path}`; - } - - return `Error: File "${file_path}" does not exist. For new files, use a patch starting with @@ -0,0 +1,N @@`; - } - - // 3. Read current file content - const originalContent = readFileSync(file_path, 'utf-8'); - const originalLines = originalContent.split('\n').length; - - // 4. Validate hunks against current file - const validationError = validateHunks(parsed.hunks, originalContent); - if (validationError) { - logDebugEvent('patch_file.validation_error', { file_path, error: validationError }); - return `Error: Patch validation failed for ${file_path}:\n${validationError}\n\nThe file may have changed since you last read it. Re-read the file and regenerate the patch.`; - } - - // 5. Apply the patch - const newContent = applyHunks(parsed.hunks, originalContent); - writeFileSync(file_path, newContent, 'utf-8'); - - // 6. Summarize - const { added, removed } = summarizeChanges(parsed.hunks); - const newLines = newContent.split('\n').length; - - logDebugEvent('patch_file.success', { - file_path, - added, - removed, - oldLines: originalLines, - newLines, - }); - return `Successfully patched ${file_path}\n ${added} line(s) added, ${removed} line(s) removed\n ${originalLines} lines → ${newLines} lines`; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logDebugEvent('patch_file.error', { file_path, error: msg }); - return `Error applying patch to ${file_path}: ${msg}`; - } - }, -}); diff --git a/src/tools/blast-radius.ts b/src/tools/blast-radius.ts deleted file mode 100644 index e5839b5..0000000 --- a/src/tools/blast-radius.ts +++ /dev/null @@ -1,284 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { parseGitLog, parseGitBlame, type Commit } from '../lib/git-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -/** - * BLAST RADIUS — weighted scoring algorithm. - * - * Given file F: - * 1. Find implicitly coupled files (threshold 0.5) — coupling logic inline - * 2. Find files with same dominant author (from blame) - * 3. Find files in same directory changed in last 90 days - * 4. Compute risk_score for each related file: - * risk = (coupling_strength × 0.5) + (author_overlap × 0.3) + (directory_proximity × 0.2) - * 5. Return combined report, sorted by risk_score - */ - -interface BlastEntry { - file: string; - riskScore: number; - couplingStrength: number; - reasonType: string; // coupling, shared-author, same-directory - detail: string; -} - -export const blastRadiusTool = tool({ - description: - 'Given a file, find everything that might break when you touch it — coupled files, shared authors, related modules. Uses weighted scoring.', - - args: { - file: tool.schema.string().describe('File path relative to repo root to analyze'), - since: tool.schema.string().describe("Only consider commits since date (e.g., '90d', '6m')"), - }, - - async execute(args, ctx) { - const targetFile = args.file as string; - const since = args.since as string | undefined; - const cwd = ctx.directory; - - logDebugEvent('blast_radius.start', { file: targetFile, since: since ?? 'none' }); - - try { - const result = await computeBlastRadius(targetFile, cwd, since); - logDebugEvent('blast_radius.done', { entries: result.length }); - return result; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('blast_radius.error', { error: msg }); - return `Error computing blast radius: ${msg}`; - } - }, -}); - -/** - * Compute blast radius for a target file. - */ -export async function computeBlastRadius( - targetFile: string, - cwd: string, - since?: string -): Promise { - // Get commits for coupling analysis - const commits = await parseGitLog(cwd, since); - - // Check if file exists in git history - const targetCommits = commits.filter((c) => c.files.some((f) => f.path === targetFile)); - if (targetCommits.length === 0) { - return `File not found in git history: ${targetFile}`; - } - - // 1. Build implicit coupling pairs (threshold 0.5) - const couplings = computeCouplingInternal(commits, 0.5); - const couplingMap = new Map(); - for (const c of couplings) { - if (c.files[0] === targetFile) { - couplingMap.set(c.files[1], c.couplingStrength); - } else if (c.files[1] === targetFile) { - couplingMap.set(c.files[0], c.couplingStrength); - } - } - - // 2. Find dominant author via blame - let dominantAuthor = ''; - let dominantAuthorPct = 0; - try { - const blameLines = await parseGitBlame(targetFile, cwd); - if (blameLines.length > 0) { - const authorCounts = new Map(); - for (const bl of blameLines) { - if (bl.author && bl.author !== 'Not Committed Yet') { - authorCounts.set(bl.author, (authorCounts.get(bl.author) ?? 0) + 1); - } - } - const total = Array.from(authorCounts.values()).reduce((s, n) => s + n, 0); - let topCount = 0; - for (const [author, count] of authorCounts) { - if (count > topCount) { - dominantAuthor = author; - topCount = count; - } - } - dominantAuthorPct = total > 0 ? topCount / total : 0; - } - } catch { - // Blame may fail for some files; continue without it - } - - // Build list of files by same author (from commit log) - const sameAuthorFiles = new Set(); - if (dominantAuthor) { - for (const commit of commits) { - if (commit.author === dominantAuthor) { - for (const f of commit.files) { - if (f.path !== targetFile && !couplingMap.has(f.path)) { - sameAuthorFiles.add(f.path); - } - } - } - } - } - - // 3. Files in same directory, changed in last 90 days - const targetDir = targetFile.includes('/') - ? targetFile.slice(0, targetFile.lastIndexOf('/')) - : '.'; - const now = Date.now(); - const ninetyDaysMs = 90 * 24 * 60 * 60 * 1000; - - const sameDirFiles = new Set(); - const recentFiles = new Set(); - for (const commit of commits) { - const commitDate = new Date(commit.date).getTime(); - if (now - commitDate > ninetyDaysMs) continue; - for (const f of commit.files) { - if (f.path === targetFile) continue; - if (couplingMap.has(f.path) || sameAuthorFiles.has(f.path)) continue; - const fDir = f.path.includes('/') ? f.path.slice(0, f.path.lastIndexOf('/')) : '.'; - if (fDir === targetDir) { - sameDirFiles.add(f.path); - } else if ( - targetDir !== '.' && - fDir !== '.' && - (fDir.startsWith(targetDir + '/') || targetDir.startsWith(fDir + '/')) - ) { - sameDirFiles.add(f.path); - } - } - for (const f of commit.files) { - recentFiles.add(f.path); - } - } - - // 4. Compute weighted risk scores - const entries: Map = new Map(); - - // Helper to add/merge entries - function addEntry( - file: string, - couplingScore: number, - authorScore: number, - dirScore: number, - reasonParts: string[] - ) { - const existing = entries.get(file); - const riskScore = couplingScore * 0.5 + authorScore * 0.3 + dirScore * 0.2; - - if (existing) { - existing.riskScore = Math.max(existing.riskScore, riskScore); - existing.couplingStrength = Math.max(existing.couplingStrength, couplingScore); - // Merge reason - if (reasonParts.length > 0) { - existing.detail = reasonParts.join(' + '); - } - } else { - const reasonType = - couplingScore > 0.5 ? 'coupling' : authorScore > 0 ? 'shared-author' : 'same-directory'; - entries.set(file, { - file, - riskScore, - couplingStrength: couplingScore, - reasonType, - detail: reasonParts.join(' + '), - }); - } - } - - // Coupled files - for (const [file, strength] of couplingMap) { - const dirScore = sameDirFiles.has(file) ? 1.0 : 0.0; - const isParent = - targetDir !== '.' && - file.includes('/') && - (file.startsWith(targetDir + '/') || - targetDir.startsWith(file.slice(0, file.lastIndexOf('/')) + '/')); - const dirProx = dirScore || (isParent ? 0.5 : 0.0); - const reasonParts = [`coupling (${strength.toFixed(2)})`]; - if (dirProx > 0) reasonParts.push('same directory'); - addEntry(file, strength, 0, dirProx, reasonParts); - } - - // Same-author files - for (const file of sameAuthorFiles) { - const dirProx = sameDirFiles.has(file) ? 1.0 : 0.5; - const reasonParts = [ - `shared author (${dominantAuthor}, ${Math.round(dominantAuthorPct * 100)}% owner)`, - ]; - if (dirProx >= 1.0) reasonParts.push('same directory'); - addEntry(file, 0, dominantAuthorPct, dirProx, reasonParts); - } - - // Same-directory files (not already covered) - for (const file of sameDirFiles) { - if (couplingMap.has(file) || sameAuthorFiles.has(file)) continue; - addEntry(file, 0, 0, 1.0, ['same directory']); - } - - // Sort by risk_score descending - const sorted = Array.from(entries.values()).sort((a, b) => b.riskScore - a.riskScore); - - // Format output - const lines: string[] = []; - lines.push(`BLAST RADIUS — ${targetFile}`); - - if (sorted.length === 0) { - lines.push(' No related files found.'); - return lines.join('\n'); - } - - // Header - lines.push(' Risk score | File | Reason'); - - for (const e of sorted) { - const scoreStr = e.riskScore.toFixed(2).padStart(10); - const fileStr = e.file.padEnd(30); - lines.push(` ${scoreStr} | ${fileStr} | ${e.detail}`); - } - - return lines.join('\n'); -} - -/** - * Internal coupling computation (reused from implicit-coupling logic). - * Using threshold 0.5 for blast radius. - */ -function computeCouplingInternal( - commits: Commit[], - threshold: number -): { files: [string, string]; couplingStrength: number }[] { - const pairCounts = new Map(); - const fileTotalCommits = new Map(); - - for (const commit of commits) { - const changedFiles = commit.files.map((f) => f.path); - if (changedFiles.length < 2) continue; - - for (const file of changedFiles) { - fileTotalCommits.set(file, (fileTotalCommits.get(file) ?? 0) + 1); - } - - for (let i = 0; i < changedFiles.length; i++) { - for (let j = i + 1; j < changedFiles.length; j++) { - const a = changedFiles[i]!; - const b = changedFiles[j]!; - const key = a < b ? `${a}|||${b}` : `${b}|||${a}`; - pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1); - } - } - } - - const results: { files: [string, string]; couplingStrength: number }[] = []; - for (const [key, count] of pairCounts) { - const [fileA, fileB] = key.split('|||') as [string, string]; - const maxCommits = Math.max(fileTotalCommits.get(fileA) ?? 0, fileTotalCommits.get(fileB) ?? 0); - if (maxCommits === 0) continue; - const strength = Math.round((count / maxCommits) * 1000) / 1000; - if (strength >= threshold) { - results.push({ files: [fileA, fileB], couplingStrength: strength }); - } - } - results.sort((a, b) => b.couplingStrength - a.couplingStrength); - return results; -} diff --git a/src/tools/bus-factor.ts b/src/tools/bus-factor.ts deleted file mode 100644 index 546fd46..0000000 --- a/src/tools/bus-factor.ts +++ /dev/null @@ -1,161 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { parseGitLog, type Commit } from '../lib/git-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -/** - * Bus factor: change-count-based approach (from git log, not blame). - * - * For each directory: - * ownership_pct = (top_author_changes / total_directory_changes) × 100 - * bus_factor = 1 if ownership_pct > 70% - * bus_factor = 2 if ownership_pct > 50% - * bus_factor = 3+ otherwise - */ - -interface DirStats { - byAuthor: Map; - total: number; -} - -interface BusFactorResult { - dir: string; - busFactor: number; - topAuthor: string; - topAuthorPct: number; - breakdown: Map; -} - -export const busFactorTool = tool({ - description: - 'Calculate bus factor per directory — ownership concentration analysis using commit change counts. Identifies modules that would be orphaned if key contributors left.', - - args: { - since: tool.schema.string().describe("Only consider commits since date (e.g., '90d', '6m')"), - }, - - async execute(args, ctx) { - const since = args.since as string | undefined; - const cwd = ctx.directory; - - logDebugEvent('bus_factor.start', { since: since ?? 'none' }); - - try { - const commits = await parseGitLog(cwd, since); - const results = computeBusFactorFromLog(commits); - - if (results.length === 0) { - return 'No git history found'; - } - - logDebugEvent('bus_factor.done', { directories: results.length }); - return formatBusFactorOutput(results); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('bus_factor.error', { error: msg }); - return `Error computing bus factor: ${msg}`; - } - }, -}); - -/** - * Compute bus factor from commit log (change-count-based, not blame-based). - */ -export function computeBusFactorFromLog(commits: Commit[]): BusFactorResult[] { - const dirStats = new Map(); - - // Helper: extract top-level (or first-level) directory from file path - function getDir(filePath: string): string { - const idx = filePath.indexOf('/'); - if (idx === -1) return '.'; - return filePath.slice(0, idx); - } - - const MIN_COMMITS = 5; - - for (const commit of commits) { - for (const f of commit.files) { - const dir = getDir(f.path); - let ds = dirStats.get(dir); - if (!ds) { - ds = { byAuthor: new Map(), total: 0 }; - dirStats.set(dir, ds); - } - ds.total++; - ds.byAuthor.set(commit.author, (ds.byAuthor.get(commit.author) ?? 0) + 1); - } - } - - const results: BusFactorResult[] = []; - - for (const [dir, ds] of dirStats) { - // Directory with < 5 commits → mark as "insufficient data" (skip) - if (ds.total < MIN_COMMITS) continue; - - // Find top author - let topAuthor = ''; - let topChanges = 0; - for (const [author, changes] of ds.byAuthor) { - if (changes > topChanges) { - topAuthor = author; - topChanges = changes; - } - } - - const topAuthorPct = Math.round((topChanges / ds.total) * 1000) / 10; - - // Bus factor: 1 if >70%, 2 if >50%, 3+ otherwise - let busFactor: number; - if (topAuthorPct > 70) { - busFactor = 1; - } else if (topAuthorPct > 50) { - busFactor = 2; - } else { - busFactor = 3; // 3+ - } - - results.push({ - dir, - busFactor, - topAuthor, - topAuthorPct, - breakdown: ds.byAuthor, - }); - } - - // Sort by bus factor (worst first), then by top author pct desc - results.sort((a, b) => { - if (a.busFactor !== b.busFactor) return a.busFactor - b.busFactor; - return b.topAuthorPct - a.topAuthorPct; - }); - - return results; -} - -/** - * Format bus factor results as plain text. - */ -function formatBusFactorOutput(results: BusFactorResult[]): string { - const lines: string[] = []; - lines.push('BUS FACTOR — per-directory ownership'); - - for (const r of results) { - const sorted = Array.from(r.breakdown.entries()).sort((a, b) => b[1] - a[1]); - const totalChanges = Array.from(r.breakdown.values()).reduce((s, v) => s + v, 0); - - // Build breakdown: "alice 82%, bob 18%" - const breakdownParts = sorted.map(([author, changes]) => { - const pct = Math.round((changes / totalChanges) * 1000) / 10; - return `${author} ${pct}%`; - }); - const detail = breakdownParts.join(', '); - - const bfLabel = r.busFactor >= 3 ? '3+' : String(r.busFactor); - const dirPad = r.dir.padEnd(14); - lines.push(` ${dirPad} → ${bfLabel} (${detail})`); - } - - return lines.join('\n'); -} diff --git a/src/tools/curse-score.ts b/src/tools/curse-score.ts deleted file mode 100644 index 4119306..0000000 --- a/src/tools/curse-score.ts +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { parseGitLog, isExcluded, type Commit } from '../lib/git-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -/** - * curse_score = changes × log₂(authors + 1) × exp(-0.5 × age_years) × log₂(churn_rate + 2) × acceleration - * - * Where: - * - changes = total number of commits touching this file - * - authors = unique author count - * - age_years = years since first commit (now - first_date) / 365.25 - * - churn_rate = changes / max(age_years, 0.25) - * - acceleration = min(changes_in_last_90d / max(changes, 1), 3.0) - */ - -interface FileStats { - file: string; - changes: number; - authors: Set; - firstDate: Date; - lastDate: Date; - recentChanges: number; // last 90 days -} - -interface CurseResult { - file: string; - score: number; - authors: number; - changes: number; - churnRate: number; -} - -export const curseScoreTool = tool({ - description: - 'Rank files by risk using curse score algorithm: changes × log₂(authors+1) × exp(-0.5×age) × log₂(churn+2) × acceleration. Returns top N most dangerous files in the repo.', - - args: { - top: tool.schema.number().describe('Number of files to return (default: 10)'), - since: tool.schema - .string() - .describe("Only consider commits since date (e.g., '90d', '6m', '2024-01-01')"), - }, - - async execute(args, ctx) { - const top = (args.top as number) ?? 10; - const since = args.since as string | undefined; - const cwd = ctx.directory; - - logDebugEvent('curse_score.start', { top, since: since ?? 'none' }); - - try { - const commits = await parseGitLog(cwd, since); - - if (commits.length === 0) { - return 'No git history found'; - } - - const results = computeCurseScores(commits, top); - logDebugEvent('curse_score.done', { count: results.length }); - return formatCurseScoreOutput(results, top); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('curse_score.error', { error: msg }); - return `Error computing curse scores: ${msg}`; - } - }, -}); - -/** - * Build per-file statistics from commit history. - * @param commits The parsed git log commits - * @param referenceDate Optional reference date for recentChanges window (default: now) - */ -function buildFileStats(commits: Commit[], referenceDate?: Date): Map { - const stats = new Map(); - const now = referenceDate ?? new Date(); - const ninetyDaysMs = 90 * 24 * 60 * 60 * 1000; - - for (const commit of commits) { - const commitDate = new Date(commit.date); - const isRecent90d = now.getTime() - commitDate.getTime() < ninetyDaysMs; - - for (const f of commit.files) { - // Skip binary files (added=0 and deleted=0 from `-` in numstat) - if (isExcluded(f.path)) continue; - - let s = stats.get(f.path); - if (!s) { - s = { - file: f.path, - changes: 0, - authors: new Set(), - firstDate: commitDate, - lastDate: commitDate, - recentChanges: 0, - }; - stats.set(f.path, s); - } - - s.changes++; - s.authors.add(commit.author); - - if (commitDate < s.firstDate) s.firstDate = commitDate; - if (commitDate > s.lastDate) s.lastDate = commitDate; - - if (isRecent90d) { - s.recentChanges++; - } - } - } - - return stats; -} - -/** - * Compute curse scores for all files, returning top N. - * @param commits The parsed git log commits - * @param topN Number of files to return - * @param referenceDate Optional reference date for curse score calculation (default: now) - */ -export function computeCurseScores( - commits: Commit[], - topN: number, - referenceDate?: Date -): CurseResult[] { - const stats = buildFileStats(commits, referenceDate); - const now = referenceDate ?? new Date(); - const yearMs = 365.25 * 24 * 60 * 60 * 1000; - - const results: CurseResult[] = []; - - for (const [, s] of stats) { - // changes: total commits touching this file - const changes = s.changes; - // authors: unique author count - const authors = s.authors.size; - // age_years: years since first commit - const ageYears = (now.getTime() - s.firstDate.getTime()) / yearMs; - const clampedAge = Math.max(ageYears, 0.25); - // churn_rate = changes / max(age_years, 0.25) - const churnRate = changes / clampedAge; - // acceleration = min(changes_in_last_90d / max(changes, 1), 3.0) - const acceleration = Math.min(s.recentChanges / Math.max(changes, 1), 3.0); - - // curse_score = changes × log₂(authors + 1) × exp(-0.5 × age_years) × log₂(churn_rate + 2) × acceleration - const score = - changes * - Math.log2(authors + 1) * - Math.exp(-0.5 * ageYears) * - Math.log2(churnRate + 2) * - acceleration; - - results.push({ - file: s.file, - score: Math.round(score * 10) / 10, - authors, - changes, - churnRate: Math.round(churnRate * 10) / 10, - }); - } - - results.sort((a, b) => b.score - a.score); - return results.slice(0, topN); -} - -/** - * Format curse score results as plain text. - */ -function formatCurseScoreOutput(results: CurseResult[], _top: number): string { - const lines: string[] = []; - lines.push(`CURSE SCORE — top ${results.length} files by risk`); - - let rank = 0; - for (const r of results) { - rank++; - const rankPad = rank.toString().padStart(3, ' '); - const filePad = r.file.padEnd(35); - const scoreStr = `score ${r.score}`.padStart(12); - const authorsStr = `${r.authors} authors`.padStart(12); - const changesStr = `${r.changes} changes`.padStart(12); - const churnStr = `churn ${r.churnRate}/yr`; - lines.push( - ` ${rankPad}. ${filePad} ${scoreStr} ${authorsStr} ${changesStr} ${churnStr}` - ); - } - - return lines.join('\n'); -} diff --git a/src/tools/gh-bot-review.ts b/src/tools/gh-bot-review.ts deleted file mode 100644 index 75edcb4..0000000 --- a/src/tools/gh-bot-review.ts +++ /dev/null @@ -1,313 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGh, resolveRepo } from '../lib/gh-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -// ──────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────── - -export interface BotFinding { - bot: string; - type: string; - severity: string; - file: string; - line: number; - description: string; - suggestion: string; - actionable: boolean; -} - -interface BotRawComment { - id?: number; - user?: { login?: string }; - state?: string; - body?: string; - submitted_at?: string; - created_at?: string; -} - -// ──────────────────────────────────────────────────────────────── -// Parsers — exported for testing -// ──────────────────────────────────────────────────────────────── - -export function parseCodeRabbit(body: string): BotFinding[] { - const findings: BotFinding[] = []; - - // Match individual file-line findings - // Pattern: "In `@`:\n- Line : " - const blockPattern = /In\s+`@?([^`]+)`:\s*([\s\S]*?)(?=\nIn\s+`@|\n\s*```|$)/g; - let blockMatch: RegExpExecArray | null; - - while ((blockMatch = blockPattern.exec(body)) !== null) { - const file = blockMatch[1]!.trim(); - const blockContent = blockMatch[2]!; - - // Find line-specific findings within this block - // Use [^\n]+ to ensure we only capture content on the same line as "Line N:" - const linePattern = /-\s*Line\s+(\d+):\s*([^\n]+)/g; - let lineMatch: RegExpExecArray | null; - - while ((lineMatch = linePattern.exec(blockContent)) !== null) { - const line = parseInt(lineMatch[1]!, 10); - const description = lineMatch[2]!.trim(); - const finding = classifyCoderabbitFinding(description, body, file, line); - findings.push(finding); - } - } - - // Fallback: if no inline findings, check for meta content - if (findings.length === 0 && body.length > 0) { - if ( - body.includes('Prompt for AI') || - body.includes('finishing touches') || - body.includes('review in progress') - ) { - findings.push({ - bot: 'coderabbitai', - type: 'meta', - severity: 'info', - file: '', - line: 0, - description: 'Review contains AI agent prompt — no inline findings parsed', - suggestion: 'Read full review body manually', - actionable: false, - }); - } - } - - return findings; -} - -function classifyCoderabbitFinding( - description: string, - fullBody: string, - file: string, - line: number -): BotFinding { - let type = 'nitpick'; - let severity = 'nitpick'; - - if ( - description.includes('peer dependency') || - description.includes('version mismatch') || - description.includes('dependency') - ) { - type = 'peer_dependency'; - severity = 'P1'; - } else if (description.includes('security') || description.includes('supply chain')) { - type = 'security'; - severity = 'P2'; - } else if (description.includes('Quick win') || description.includes('Consider')) { - type = 'nitpick'; - severity = 'nitpick'; - } else if ( - description.includes('pin') || - description.includes('action') || - description.includes('commit hash') - ) { - type = 'security'; - severity = 'P2'; - } - - // Try to extract a suggestion from the full body - const suggestionMatch = fullBody.match(/Update the[^.]*\./); - const suggestion = suggestionMatch ? suggestionMatch[0] : description; - - return { - bot: 'coderabbitai', - type, - severity, - file, - line, - description, - suggestion, - actionable: severity !== 'nitpick', - }; -} - -export function parseCubicDev(body: string): BotFinding[] { - const findings: BotFinding[] = []; - - // Match blocks - const filePattern = /([\s\S]*?)<\/file>/g; - let fileMatch: RegExpExecArray | null; - - while ((fileMatch = filePattern.exec(body)) !== null) { - const file = fileMatch[1]!; - const fileContent = fileMatch[2]!; - - // Match children - const violationPattern = - /\s*\n?([\s\S]*?)<\/violation>/g; - let violationMatch: RegExpExecArray | null; - - while ((violationMatch = violationPattern.exec(fileContent)) !== null) { - const _violationNum = violationMatch[1]!; - const line = parseInt(violationMatch[2]!, 10); - const violationText = violationMatch[3]!.trim(); - - // Extract severity P1/P2 and description - const severityMatch = violationText.match(/^(P[12]):\s*(.*)/s); - const severity = severityMatch ? severityMatch[1]! : 'P2'; - const description = severityMatch ? severityMatch[2]!.trim() : violationText; - - findings.push({ - bot: 'cubic-dev-ai', - type: 'bug', - severity, - file, - line, - description, - suggestion: '', - actionable: true, - }); - } - } - - return findings; -} - -export function parseDependabot(body: string): BotFinding[] { - const bumpMatch = body.match(/Bumps?\s+(.+?)\s+from\s+(\S+)\s+to\s+(\S+)/i); - if (bumpMatch) { - return [ - { - bot: 'dependabot', - type: 'dependency', - severity: 'info', - file: 'package.json', - line: 0, - description: `Bump ${bumpMatch[1]!.trim()} from ${bumpMatch[2]!} to ${bumpMatch[3]!}`, - suggestion: 'Review changelog for breaking changes, verify CI passes', - actionable: true, - }, - ]; - } - return []; -} - -export function parseBotContent(body: string, username: string): BotFinding[] { - const findings: BotFinding[] = []; - - if (username.includes('coderabbitai')) { - findings.push(...parseCodeRabbit(body)); - } - if (username.includes('cubic-dev-ai')) { - findings.push(...parseCubicDev(body)); - } - if (username.includes('dependabot')) { - findings.push(...parseDependabot(body)); - } - - return findings; -} - -// ──────────────────────────────────────────────────────────────── -// Tool definition -// ──────────────────────────────────────────────────────────────── - -export const ghBotReviewTool = tool({ - description: - 'Parse AI bot review comments on a PR and extract structured, actionable findings. Reads reviews from coderabbitai, cubic-dev-ai, and dependabot bots. Use BEFORE fixing PR issues to understand what the bots found.', - - args: { - pr: tool.schema.number().describe('PR number to check'), - repo: tool.schema - .string() - .optional() - .describe('Repo in owner/repo format (default: current repo from git remote)'), - bot: tool.schema - .string() - .optional() - .describe('Filter by bot: "coderabbitai", "cubic-dev-ai", "dependabot", or "all" (default)'), - }, - - async execute(args, ctx) { - const { pr, bot } = args; - const cwd = ctx.directory; - - logDebugEvent('gh_bot_review.start', { pr, bot }); - - try { - const resolvedRepo = await resolveRepo((args.repo as string | undefined) || undefined, cwd); - - // Fetch reviews - const reviewsRaw = await runGh( - [ - 'api', - `repos/${resolvedRepo}/pulls/${pr}/reviews`, - '--jq', - 'map({id, user: .user.login, state, body, submitted_at})', - ], - cwd - ); - - // Fetch issue-level comments (PR comments are stored as issue comments) - const commentsRaw = await runGh( - [ - 'api', - `repos/${resolvedRepo}/issues/${pr}/comments`, - '--jq', - 'map({id, user: .user.login, body, created_at})', - ], - cwd - ); - - const findings: BotFinding[] = []; - - // Parse reviews - let reviews: BotRawComment[] = []; - try { - reviews = JSON.parse(reviewsRaw) as BotRawComment[]; - } catch { - reviews = []; - } - if (!Array.isArray(reviews)) reviews = []; - - // Parse comments - let comments: BotRawComment[] = []; - try { - comments = JSON.parse(commentsRaw) as BotRawComment[]; - } catch { - comments = []; - } - if (!Array.isArray(comments)) comments = []; - - const filterBot = (bot as string | undefined) || 'all'; - const allItems = [ - ...reviews.map((r) => ({ user: r.user, body: r.body ?? '' })), - ...comments.map((c) => ({ user: c.user, body: c.body ?? '' })), - ]; - - for (const item of allItems) { - const username = item.user?.login ?? ''; - if ( - !username.includes('coderabbitai') && - !username.includes('cubic-dev-ai') && - !username.includes('dependabot') - ) { - continue; - } - if (filterBot !== 'all' && !username.includes(filterBot)) continue; - - const extracted = parseBotContent(item.body, username); - findings.push(...extracted); - } - - logDebugEvent('gh_bot_review.done', { pr, findings: findings.length }); - - if (findings.length === 0) { - return `No bot findings on PR #${pr}`; - } - - return JSON.stringify(findings, null, 2); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logDebugEvent('gh_bot_review.error', { error: msg }); - return `Error: ${msg}`; - } - }, -}); diff --git a/src/tools/gh-branch-cleanup.ts b/src/tools/gh-branch-cleanup.ts deleted file mode 100644 index af4ae21..0000000 --- a/src/tools/gh-branch-cleanup.ts +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGh, resolveRepo } from '../lib/gh-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -// ──────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────── - -interface MergedPr { - number: number; - headRefName: string; - baseRefName: string; - mergedAt: string; -} - -// ──────────────────────────────────────────────────────────────── -// Output formatting -// ──────────────────────────────────────────────────────────────── - -function formatBranchCleanup( - branches: MergedPr[], - deleted: string[], - failed: string[], - dryRun: boolean, - repo: string -): string { - const lines: string[] = []; - - if (dryRun) { - lines.push(`GH BRANCH CLEANUP — ${repo} — DRY RUN`); - lines.push(''); - - if (branches.length === 0) { - lines.push(' No stale merged branches found.'); - return lines.join('\n'); - } - - lines.push(` Found ${branches.length} merged branches with closed PRs:`); - lines.push(''); - for (const b of branches) { - const dateStr = b.mergedAt.slice(0, 10); - lines.push(` • ${b.headRefName} (PR #${b.number} → ${b.baseRefName}, merged ${dateStr})`); - } - lines.push(''); - lines.push(' Run with dry_run=false to delete these branches.'); - } else { - lines.push(`GH BRANCH CLEANUP — ${repo}`); - lines.push(''); - - if (deleted.length > 0) { - lines.push(` Deleted ${deleted.length} branch${deleted.length !== 1 ? 'es' : ''}:`); - for (const d of deleted) { - lines.push(` ✓ ${d}`); - } - } - - if (failed.length > 0) { - lines.push(''); - lines.push(` Failed to delete ${failed.length} branch${failed.length !== 1 ? 'es' : ''}:`); - for (const f of failed) { - lines.push(` ✗ ${f}`); - } - } - - if (deleted.length === 0 && failed.length === 0) { - lines.push(' No stale merged branches to delete.'); - } - } - - return lines.join('\n'); -} - -// ──────────────────────────────────────────────────────────────── -// Tool definition -// ──────────────────────────────────────────────────────────────── - -export const ghBranchCleanupTool = tool({ - description: - 'Find and delete stale merged remote branches. Identifies branches whose PRs have been merged but the branch still exists on the remote. Use `dry_run=true` (default) to preview.', - - args: { - repo: tool.schema - .string() - .describe('GitHub repo in owner/repo format (defaults to current repo)'), - dry_run: tool.schema - .boolean() - .describe('Preview branches without deleting (default: true — SAFETY FIRST)'), - limit: tool.schema.number().describe('Maximum number of merged PRs to check (default: 50)'), - }, - - async execute(args, ctx) { - const repo = args.repo as string | undefined; - const dryRun = (args.dry_run as boolean) ?? true; - const limit = (args.limit as number) ?? 50; - const cwd = ctx.directory; - - logDebugEvent('gh_branch_cleanup.start', { dryRun, limit }); - - try { - const resolvedRepo = await resolveRepo(repo, cwd); - - // ── Step 1: List merged PRs ── - const ghArgs: string[] = [ - 'pr', - 'list', - '--repo', - resolvedRepo, - '--state', - 'merged', - '--limit', - String(limit), - '--json', - 'number,headRefName,baseRefName,mergedAt', - ]; - - const rawJson = await runGh(ghArgs, cwd); - - let mergedPrs: MergedPr[]; - try { - mergedPrs = JSON.parse(rawJson) as MergedPr[]; - } catch { - return `Error parsing gh pr list output. Raw output:\n${rawJson}`; - } - - if (mergedPrs.length === 0) { - return formatBranchCleanup([], [], [], dryRun, resolvedRepo); - } - - // Filter out branches merged to main or master - const staleBranches = mergedPrs.filter( - (pr) => pr.baseRefName === 'main' || pr.baseRefName === 'master' - ); - - if (dryRun) { - logDebugEvent('gh_branch_cleanup.done', { found: staleBranches.length, dryRun: true }); - return formatBranchCleanup(staleBranches, [], [], true, resolvedRepo); - } - - // ── Step 2: Delete each stale branch ── - const deleted: string[] = []; - const failed: string[] = []; - - for (const pr of staleBranches) { - const branch = pr.headRefName; - try { - await runGh( - [ - 'api', - `repos/${resolvedRepo}/git/refs/heads/${branch}`, - '--method', - 'DELETE', - '--silent', - ], - cwd - ); - deleted.push(branch); - } catch { - failed.push(branch); - } - } - - logDebugEvent('gh_branch_cleanup.done', { deleted: deleted.length, failed: failed.length }); - return formatBranchCleanup(staleBranches, deleted, failed, false, resolvedRepo); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('gh_branch_cleanup.error', { error: msg }); - return `Error cleaning up branches: ${msg}`; - } - }, -}); - -export { formatBranchCleanup }; diff --git a/src/tools/gh-issue-close.ts b/src/tools/gh-issue-close.ts deleted file mode 100644 index 57a0b28..0000000 --- a/src/tools/gh-issue-close.ts +++ /dev/null @@ -1,131 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGh, resolveRepo } from '../lib/gh-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -// ──────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────── - -interface IssueView { - number: number; - title: string; - state: string; - closedByPullRequestsUrls: string[]; -} - -// ──────────────────────────────────────────────────────────────── -// Tool definition -// ──────────────────────────────────────────────────────────────── - -export const ghIssueCloseTool = tool({ - description: - 'Close a GitHub issue with optional comment. Auto-detects zombie issues (merged PR but issue still open). Saves ~90% tokens vs. bash→read→parse. Use for issue lifecycle management.', - - args: { - issue: tool.schema.number().describe('Issue number to close'), - reason: tool.schema - .string() - .describe("Close reason: 'completed' or 'not planned' (default: 'completed')"), - comment: tool.schema.string().describe('Optional comment to post before closing'), - repo: tool.schema - .string() - .describe('GitHub repo in owner/repo format (defaults to current repo)'), - }, - - async execute(args, ctx) { - const issueNum = args.issue as number; - const reason = ((args.reason as string) ?? 'completed').toLowerCase(); - const comment = args.comment as string | undefined; - const repo = args.repo as string | undefined; - const cwd = ctx.directory; - - logDebugEvent('gh_issue_close.start', { issue: issueNum, reason, hasComment: !!comment }); - - try { - const resolvedRepo = await resolveRepo(repo, cwd); - - // Validate reason - if (!['completed', 'not planned'].includes(reason)) { - return `Error: Invalid reason "${reason}". Must be "completed" or "not planned".`; - } - - // ── Step 1: Check if issue is already closed ── - let issueView: IssueView; - try { - const viewJson = await runGh( - [ - 'issue', - 'view', - String(issueNum), - '--repo', - resolvedRepo, - '--json', - 'number,title,state,closedByPullRequestsUrls', - ], - cwd - ); - issueView = JSON.parse(viewJson) as IssueView; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return `Error viewing issue #${issueNum}: ${msg}`; - } - - if (issueView.state === 'closed') { - return `Issue #${issueNum} "${issueView.title}" is already closed. Nothing to do.`; - } - - // ── Step 2: Zombie detection ── - const urls = issueView.closedByPullRequestsUrls || []; - const isZombie = urls.length > 0; - - let outputLines: string[] = []; - - if (isZombie) { - outputLines.push('⚠️ ZOMBIE ISSUE DETECTED'); - outputLines.push(` Issue #${issueNum} "${issueView.title}" was closed by merged PR(s):`); - for (const url of urls) { - outputLines.push(` • ${url}`); - } - outputLines.push(' The associated PR is merged but the issue remained open.'); - outputLines.push(''); - } - - // ── Step 3: Post optional comment ── - if (comment) { - try { - await runGh( - ['issue', 'comment', String(issueNum), '--repo', resolvedRepo, '--body', comment], - cwd - ); - outputLines.push(`✓ Comment posted on #${issueNum}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - outputLines.push(`⚠ Failed to post comment: ${msg}`); - } - } - - // ── Step 4: Close the issue ── - try { - await runGh( - ['issue', 'close', String(issueNum), '--repo', resolvedRepo, '--reason', reason], - cwd - ); - outputLines.push(`✓ Issue #${issueNum} "${issueView.title}" closed as "${reason}"`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - outputLines.push(`✗ Failed to close issue: ${msg}`); - return outputLines.join('\n'); - } - - logDebugEvent('gh_issue_close.done', { issue: issueNum, zombie: isZombie }); - return outputLines.join('\n'); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('gh_issue_close.error', { error: msg }); - return `Error closing issue: ${msg}`; - } - }, -}); diff --git a/src/tools/gh-issue-list.ts b/src/tools/gh-issue-list.ts deleted file mode 100644 index b214091..0000000 --- a/src/tools/gh-issue-list.ts +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGh, resolveRepo } from '../lib/gh-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -// ──────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────── - -interface IssueInfo { - number: number; - title: string; - state: string; - labels: string[]; - assignees: string[]; - url: string; - updatedAt: string; -} - -// ──────────────────────────────────────────────────────────────── -// Output formatting -// ──────────────────────────────────────────────────────────────── - -function formatIssueList(issues: IssueInfo[], repo: string, state: string): string { - if (issues.length === 0) { - return `GH ISSUE LIST — ${repo} — no ${state} issues found.`; - } - - const lines: string[] = []; - lines.push( - `GH ISSUE LIST — ${repo} — ${issues.length} ${state} issue${issues.length !== 1 ? 's' : ''}` - ); - lines.push(''); - - for (const issue of issues) { - const labelStr = issue.labels.length > 0 ? ` [${issue.labels.join(', ')}]` : ''; - const assigneeStr = issue.assignees.length > 0 ? ` (@${issue.assignees.join(', @')})` : ''; - const dateStr = issue.updatedAt.slice(0, 10); - - lines.push(` #${String(issue.number).padEnd(6)} ${issue.title}`); - lines.push(` ${issue.state}${labelStr}${assigneeStr} — updated ${dateStr}`); - lines.push(` ${issue.url}`); - lines.push(''); - } - - return lines.join('\n').trimEnd(); -} - -// ──────────────────────────────────────────────────────────────── -// Tool definition -// ──────────────────────────────────────────────────────────────── - -export const ghIssueListTool = tool({ - description: - 'List GitHub issues with filtering. Wraps `gh issue list --json` into structured output. Saves ~90% tokens vs. bash→read→parse. Use for triage, backlog grooming, and issue discovery.', - - args: { - repo: tool.schema - .string() - .describe('GitHub repo in owner/repo format (defaults to current repo)'), - state: tool.schema - .string() - .describe("Issue state filter: 'open', 'closed', or 'all' (default: 'open')"), - label: tool.schema - .string() - .describe('Filter by label (comma-separated for multiple, e.g. "bug,help wanted")'), - assignee: tool.schema - .string() - .describe('Filter by assignee username (use "@me" for current user)'), - limit: tool.schema.number().describe('Maximum number of issues to return (default: 30)'), - search: tool.schema.string().describe('Search term to filter issues by title/body'), - }, - - async execute(args, ctx) { - const repo = args.repo as string | undefined; - const state = ((args.state as string) ?? 'open').toLowerCase(); - const label = args.label as string | undefined; - const assignee = args.assignee as string | undefined; - const limit = (args.limit as number) ?? 30; - const search = args.search as string | undefined; - const cwd = ctx.directory; - - logDebugEvent('gh_issue_list.start', { repo, state, label, assignee, limit, search }); - - try { - const resolvedRepo = await resolveRepo(repo, cwd); - - // Validate state - if (!['open', 'closed', 'all'].includes(state)) { - return `Error: Invalid state "${state}". Must be "open", "closed", or "all".`; - } - - // Build gh args - const ghArgs: string[] = [ - 'issue', - 'list', - '--repo', - resolvedRepo, - '--state', - state, - '--limit', - String(limit), - '--json', - 'number,title,state,labels,assignees,url,updatedAt', - ]; - - if (label) { - ghArgs.push('--label', label); - } - if (assignee) { - ghArgs.push('--assignee', assignee); - } - if (search) { - ghArgs.push('--search', search); - } - - const rawJson = await runGh(ghArgs, cwd); - - let issues: IssueInfo[]; - try { - issues = JSON.parse(rawJson) as IssueInfo[]; - } catch { - return `Error parsing gh issue list output. Raw output:\n${rawJson}`; - } - - // Extract label names (gh returns {name, color, ...} objects) - const normalized = issues.map((issue) => ({ - number: issue.number, - title: issue.title, - state: issue.state, - labels: (issue.labels || []).map((l: unknown) => - typeof l === 'string' ? l : ((l as { name?: string })?.name ?? String(l)) - ), - assignees: (issue.assignees || []).map((a: unknown) => - typeof a === 'string' ? a : ((a as { login?: string })?.login ?? String(a)) - ), - url: issue.url, - updatedAt: issue.updatedAt, - })); - - logDebugEvent('gh_issue_list.done', { count: normalized.length }); - return formatIssueList(normalized, resolvedRepo, state); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('gh_issue_list.error', { error: msg }); - return `Error listing issues: ${msg}`; - } - }, -}); - -export { formatIssueList }; diff --git a/src/tools/gh-pr-comment.ts b/src/tools/gh-pr-comment.ts deleted file mode 100644 index 1d01dbb..0000000 --- a/src/tools/gh-pr-comment.ts +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGh, resolveRepo } from '../lib/gh-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -export const ghPrCommentTool = tool({ - description: 'Add a comment to a GitHub pull request. Saves ~90% tokens vs. bash→read→parse.', - - args: { - pr: tool.schema.number().describe('PR number to comment on'), - body: tool.schema.string().describe('Comment text (markdown)'), - repo: tool.schema - .string() - .optional() - .describe('GitHub repo in owner/repo format (defaults to current repo)'), - }, - - async execute(args, ctx) { - const { pr, body, repo } = args; - - logDebugEvent('gh_pr_comment.start', { pr }); - - try { - const resolvedRepo = await resolveRepo(repo, ctx.directory); - const repoArgs = ['-R', resolvedRepo]; - - const output = await runGh( - ['pr', 'comment', String(pr), ...repoArgs, '--body', body], - ctx.directory - ); - - logDebugEvent('gh_pr_comment.success', { pr }); - return `✅ Comment added to PR #${pr}. ${output.trim()}`; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('gh_pr_comment.error', { error: msg }); - return `Error commenting on PR: ${msg}`; - } - }, -}); diff --git a/src/tools/gh-pr-create.ts b/src/tools/gh-pr-create.ts deleted file mode 100644 index 432f632..0000000 --- a/src/tools/gh-pr-create.ts +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGh, resolveRepo } from '../lib/gh-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -export const ghPrCreateTool = tool({ - description: 'Create a GitHub pull request. Saves ~90% tokens vs. bash→read→parse.', - - args: { - title: tool.schema.string().describe('PR title'), - body: tool.schema.string().optional().describe('PR description'), - base: tool.schema.string().optional().describe('Target branch (default: main)'), - head: tool.schema.string().optional().describe('Source branch (default: current branch)'), - draft: tool.schema.boolean().optional().describe('Create as draft PR'), - repo: tool.schema - .string() - .optional() - .describe('GitHub repo in owner/repo format (defaults to current repo)'), - }, - - async execute(args, ctx) { - const { title, body, base, head, draft, repo } = args; - - logDebugEvent('gh_pr_create.start', { title, base, head, draft }); - - try { - const resolvedRepo = await resolveRepo(repo, ctx.directory); - const repoArgs = ['-R', resolvedRepo]; - - const ghArgs = ['pr', 'create', ...repoArgs, '--title', title]; - if (body) ghArgs.push('--body', body); - if (base) ghArgs.push('--base', base); - if (head) ghArgs.push('--head', head); - if (draft) ghArgs.push('--draft'); - - const output = await runGh(ghArgs, ctx.directory); - // gh pr create outputs the PR URL on success - logDebugEvent('gh_pr_create.success', { title }); - return `✅ PR created: ${output.trim()}`; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('gh_pr_create.error', { error: msg }); - return `Error creating PR: ${msg}`; - } - }, -}); diff --git a/src/tools/gh-pr-review.ts b/src/tools/gh-pr-review.ts deleted file mode 100644 index 9fa48dd..0000000 --- a/src/tools/gh-pr-review.ts +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGh, resolveRepo } from '../lib/gh-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -export const ghPrReviewTool = tool({ - description: - 'Fetch review comments and reviews on a GitHub pull request. Returns structured feedback including review state (APPROVED/CHANGES_REQUESTED/COMMENTED) and comment bodies. Saves ~90% tokens vs. bash→read→parse.', - - args: { - pr: tool.schema.number().describe('PR number to review'), - repo: tool.schema - .string() - .optional() - .describe('GitHub repo in owner/repo format (defaults to current repo)'), - }, - - async execute(args, ctx) { - const { pr, repo } = args; - - logDebugEvent('gh_pr_review.start', { pr }); - - try { - const resolvedRepo = await resolveRepo(repo, ctx.directory); - const repoArgs = ['-R', resolvedRepo]; - - // Fetch issue-level comments + review summaries - // Note: `comments` are issue/timeline comments; `reviews` are formal review submissions. - // Inline diff comments (with path/line) require GraphQL — out of scope for v1. - const output = await runGh( - ['pr', 'view', String(pr), ...repoArgs, '--json', 'comments,reviews'], - ctx.directory - ); - - const data = JSON.parse(output) as { - comments?: Array<{ - author?: { login?: string }; - body?: string; - createdAt?: string; - }>; - reviews?: Array<{ - author?: { login?: string }; - state?: string; - body?: string; - submittedAt?: string; - }>; - }; - - const comments = data.comments ?? []; - const reviews = data.reviews ?? []; - - if (comments.length === 0 && reviews.length === 0) { - return `No review comments on PR #${pr}.`; - } - - const lines: string[] = [`Review comments for PR #${pr}:`, '']; - - if (reviews.length > 0) { - lines.push('## Reviews'); - lines.push(''); - for (const review of reviews) { - const reviewer = review.author?.login ?? 'unknown'; - const state = review.state ?? 'COMMENTED'; - const ts = review.submittedAt ? ` (${review.submittedAt})` : ''; - const bodyText = (review.body ?? '').trim() || '(no comment)'; - const body = bodyText.length > 500 ? `${bodyText.substring(0, 500)}…` : bodyText; - lines.push(`[${state}] ${reviewer}${ts}:`); - lines.push(body); - lines.push(''); - } - } - - if (comments.length > 0) { - lines.push('## Comments'); - lines.push(''); - for (const comment of comments) { - const author = comment.author?.login ?? 'unknown'; - const ts = comment.createdAt ? ` (${comment.createdAt})` : ''; - const body = - (comment.body ?? '').length > 300 - ? `${comment.body!.substring(0, 300)}…` - : (comment.body ?? ''); - lines.push(`${author}${ts}:`); - lines.push(` ${body}`); - lines.push(''); - } - } - - logDebugEvent('gh_pr_review.success', { - pr, - comments: comments.length, - reviews: reviews.length, - }); - - return lines.join('\n').trimEnd(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('gh_pr_review.error', { error: msg }); - return `Error fetching PR comments: ${msg}`; - } - }, -}); diff --git a/src/tools/gh-pr-status.ts b/src/tools/gh-pr-status.ts deleted file mode 100644 index 3414926..0000000 --- a/src/tools/gh-pr-status.ts +++ /dev/null @@ -1,226 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGh, resolveRepo } from '../lib/gh-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -// ──────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────── - -interface ReviewInfo { - author: string; - state: string; - submittedAt?: string; -} - -interface CheckInfo { - name: string; - status: string; - conclusion: string | null; -} - -interface PrView { - number: number; - title: string; - state: string; - mergeable: string; // 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' - mergeStateStatus: string; - reviews: ReviewInfo[]; - statusCheckRollup: CheckInfo[] | null; - url: string; - baseRefName: string; - headRefName: string; -} - -// ──────────────────────────────────────────────────────────────── -// Helper: format mergeability status -// ──────────────────────────────────────────────────────────────── - -function mergeableIcon(status: string): string { - switch (status) { - case 'MERGEABLE': - return '✅'; - case 'CONFLICTING': - return '❌'; - default: - return '❓'; - } -} - -function mergeStateLabel(status: string): string { - switch (status) { - case 'CLEAN': - return 'ready to merge'; - case 'BLOCKED': - return 'blocked'; - case 'BEHIND': - return 'behind base'; - case 'DIRTY': - return 'needs update'; - case 'HAS_HOOKS': - return 'hooks running'; - case 'UNKNOWN': - return 'unknown state'; - case 'UNSTABLE': - return 'merging into a non-stable branch'; - default: - return status.toLowerCase(); - } -} - -// ──────────────────────────────────────────────────────────────── -// Output formatting -// ──────────────────────────────────────────────────────────────── - -function formatPrStatus(pr: PrView, repo: string): string { - const lines: string[] = []; - - lines.push(`PR STATUS — ${repo} #${pr.number}`); - lines.push(''); - lines.push(` Title: ${pr.title}`); - lines.push(` State: ${pr.state.toUpperCase()}`); - lines.push(` Branch: ${pr.headRefName} → ${pr.baseRefName}`); - lines.push(` Mergeable: ${mergeableIcon(pr.mergeable)} ${pr.mergeable}`); - lines.push(` Status: ${mergeStateLabel(pr.mergeStateStatus)}`); - lines.push(` URL: ${pr.url}`); - lines.push(''); - - // ── Reviews ── - if (pr.reviews && pr.reviews.length > 0) { - const approved = pr.reviews.filter((r) => r.state === 'APPROVED'); - const changesRequested = pr.reviews.filter((r) => r.state === 'CHANGES_REQUESTED'); - const commented = pr.reviews.filter((r) => r.state === 'COMMENTED'); - - lines.push(' Reviews:'); - if (approved.length > 0) { - lines.push( - ` ✅ ${approved.length} approval${approved.length !== 1 ? 's' : ''} (${approved.map((r) => r.author).join(', ')})` - ); - } - if (changesRequested.length > 0) { - lines.push( - ` ❌ ${changesRequested.length} change${changesRequested.length !== 1 ? 's' : ''} requested (${changesRequested.map((r) => r.author).join(', ')})` - ); - } - if (commented.length > 0) { - lines.push( - ` 💬 ${commented.length} comment${commented.length !== 1 ? 's' : ''} without decision (${commented.map((r) => r.author).join(', ')})` - ); - } - } else { - lines.push(' Reviews: none yet'); - } - - lines.push(''); - - // ── CI Status ── - if (pr.statusCheckRollup && pr.statusCheckRollup.length > 0) { - lines.push(' CI Checks:'); - for (const check of pr.statusCheckRollup) { - let icon: string; - if (check.status === 'COMPLETED') { - icon = check.conclusion === 'SUCCESS' ? '✅' : check.conclusion === 'FAILURE' ? '❌' : '⚠️'; - } else if (check.status === 'IN_PROGRESS') { - icon = '🔄'; - } else { - icon = '⏳'; - } - const conclusion = check.conclusion ? ` — ${check.conclusion}` : ''; - lines.push(` ${icon} ${check.name} (${check.status}${conclusion})`); - } - } else { - lines.push(' CI Checks: none configured'); - } - - lines.push(''); - - // ── Merge recommendation ── - const hasApproval = (pr.reviews || []).some((r) => r.state === 'APPROVED'); - const hasChangesRequested = (pr.reviews || []).some((r) => r.state === 'CHANGES_REQUESTED'); - const ciFailed = (pr.statusCheckRollup || []).some( - (c) => c.status === 'COMPLETED' && c.conclusion === 'FAILURE' - ); - const ciPending = (pr.statusCheckRollup || []).some( - (c) => c.status === 'IN_PROGRESS' || c.status === 'PENDING' - ); - - lines.push(' ── Merge Readiness ──'); - - if (pr.mergeable === 'UNKNOWN') { - lines.push(' ❓ Mergeability unknown'); - } else if (pr.mergeable === 'CONFLICTING') { - lines.push(' ❌ Has merge conflicts — resolve before merging'); - } else if (hasChangesRequested) { - lines.push(' ❌ Changes requested — address review feedback'); - } else if (ciFailed) { - lines.push(' ❌ CI checks failing — fix before merging'); - } else if (!hasApproval) { - lines.push(' ⏳ Waiting for review approval'); - } else if (ciPending) { - lines.push(' ⏳ CI checks still running'); - } else if (pr.mergeable === 'MERGEABLE' && pr.mergeStateStatus === 'CLEAN') { - lines.push(' ✅ Ready to merge! All checks passed, approved, no conflicts'); - } else if (pr.mergeable === 'MERGEABLE') { - lines.push(' ⚠️ Mergeable but not clean — status: ' + mergeStateLabel(pr.mergeStateStatus)); - } - - return lines.join('\n'); -} - -// ──────────────────────────────────────────────────────────────── -// Tool definition -// ──────────────────────────────────────────────────────────────── - -export const ghPrStatusTool = tool({ - description: - 'Check PR mergeability status — reviews, CI checks, conflicts. Wraps `gh pr view --json` into structured output. Saves ~90% tokens vs. bash→read→parse. Use before merging any PR.', - - args: { - pr: tool.schema.number().describe('PR number to check'), - repo: tool.schema - .string() - .describe('GitHub repo in owner/repo format (defaults to current repo)'), - }, - - async execute(args, ctx) { - const prNum = args.pr as number; - const repo = args.repo as string | undefined; - const cwd = ctx.directory; - - logDebugEvent('gh_pr_status.start', { pr: prNum }); - - try { - const resolvedRepo = await resolveRepo(repo, cwd); - - const ghArgs: string[] = [ - 'pr', - 'view', - String(prNum), - '--repo', - resolvedRepo, - '--json', - 'number,title,state,mergeable,mergeStateStatus,reviews,statusCheckRollup,url,baseRefName,headRefName', - ]; - - const rawJson = await runGh(ghArgs, cwd); - - let pr: PrView; - try { - pr = JSON.parse(rawJson) as PrView; - } catch { - return `Error parsing gh pr view output. Raw output:\n${rawJson}`; - } - - logDebugEvent('gh_pr_status.done', { pr: prNum, mergeable: pr.mergeable }); - return formatPrStatus(pr, resolvedRepo); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('gh_pr_status.error', { error: msg }); - return `Error checking PR status: ${msg}`; - } - }, -}); - -export { formatPrStatus }; diff --git a/src/tools/gh-release-info.ts b/src/tools/gh-release-info.ts deleted file mode 100644 index fa3d026..0000000 --- a/src/tools/gh-release-info.ts +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGh, resolveRepo } from '../lib/gh-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -// ──────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────── - -interface AssetInfo { - name: string; - size: number; - downloadCount: number; - url: string; -} - -interface ReleaseInfo { - tagName: string; - name: string | null; - body: string; - publishedAt: string; - url: string; - assets: AssetInfo[]; -} - -// ──────────────────────────────────────────────────────────────── -// Output formatting -// ──────────────────────────────────────────────────────────────── - -function formatSize(bytes: number): string { - if (bytes >= 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - } - if (bytes >= 1024) { - return `${(bytes / 1024).toFixed(1)} KB`; - } - return `${bytes} B`; -} - -function formatReleaseInfo(release: ReleaseInfo, repo: string): string { - const lines: string[] = []; - const title = release.name || release.tagName; - const dateStr = release.publishedAt.slice(0, 10); - - lines.push(`GH RELEASE — ${repo}`); - lines.push(''); - lines.push(` Tag: ${release.tagName}`); - lines.push(` Title: ${title}`); - lines.push(` Published: ${dateStr}`); - lines.push(` URL: ${release.url}`); - - // ── Release notes ── - if (release.body && release.body.trim()) { - lines.push(''); - lines.push(' Release Notes:'); - // Indent each line of the body by 4 spaces - const bodyLines = release.body.split('\n'); - for (const bl of bodyLines) { - lines.push(` ${bl}`); - } - } - - // ── Assets ── - if (release.assets && release.assets.length > 0) { - lines.push(''); - lines.push(` Assets (${release.assets.length}):`); - for (const asset of release.assets) { - lines.push( - ` • ${asset.name} — ${formatSize(asset.size)} — ${asset.downloadCount} downloads` - ); - } - } else { - lines.push(''); - lines.push(' Assets: none'); - } - - return lines.join('\n'); -} - -// ──────────────────────────────────────────────────────────────── -// Tool definition -// ──────────────────────────────────────────────────────────────── - -export const ghReleaseInfoTool = tool({ - description: - 'Get structured release metadata — version, tag, date, notes, assets. Wraps `gh release view --json`. Saves ~90% tokens vs. bash→read→parse. Defaults to latest release if no tag specified.', - - args: { - tag: tool.schema - .string() - .describe('Release tag to view (e.g., "v1.0.0"). Omit for latest release.'), - repo: tool.schema - .string() - .describe('GitHub repo in owner/repo format (defaults to current repo)'), - }, - - async execute(args, ctx) { - const tag = args.tag as string | undefined; - const repo = args.repo as string | undefined; - const cwd = ctx.directory; - - logDebugEvent('gh_release_info.start', { tag: tag ?? 'latest' }); - - try { - const resolvedRepo = await resolveRepo(repo, cwd); - - // Build gh args - const ghArgs: string[] = [ - 'release', - 'view', - '--repo', - resolvedRepo, - '--json', - 'tagName,name,body,publishedAt,url,assets', - ]; - - // If tag specified, add it; otherwise gh release view defaults to latest - if (tag) { - ghArgs.push(tag); - } - - const rawJson = await runGh(ghArgs, cwd); - - let release: ReleaseInfo; - try { - release = JSON.parse(rawJson) as ReleaseInfo; - } catch { - return `Error parsing gh release view output. Raw output:\n${rawJson}`; - } - - logDebugEvent('gh_release_info.done', { tag: release.tagName }); - return formatReleaseInfo(release, resolvedRepo); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('gh_release_info.error', { error: msg }); - if (msg.includes('No release found')) { - return `Error: No release found${tag ? ` for tag "${tag}"` : ''} in ${repo || 'current repo'}.`; - } - return `Error getting release info: ${msg}`; - } - }, -}); - -export { formatReleaseInfo }; diff --git a/src/tools/git-diff.ts b/src/tools/git-diff.ts deleted file mode 100644 index 43d044c..0000000 --- a/src/tools/git-diff.ts +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGit } from '../lib/git-utils'; -import { parseUnifiedDiff } from '../lib/diff-parse'; -import { logDebugEvent } from '../lib/debug-logger'; - -// ──────────────────────────────────────────────────────────────── -// Types -// ──────────────────────────────────────────────────────────────── - -interface FileDiffInfo { - path: string; - status: 'new' | 'deleted' | 'modified'; - added: number; - deleted: number; -} - -// ──────────────────────────────────────────────────────────────── -// Multi-file diff parsing -// ──────────────────────────────────────────────────────────────── - -/** - * Parse a multi-file unified diff output from `git diff`. - * Splits by `diff --git` headers and computes per-file stats. - */ -function parseMultiFileDiff(rawDiff: string): FileDiffInfo[] { - const results: FileDiffInfo[] = []; - - // Split on diff --git headers (preserving the delimiter) - const sections = rawDiff.split(/(?=^diff --git )/m); - - for (const section of sections) { - const trimmed = section.trim(); - if (!trimmed) continue; - - const lines = trimmed.split('\n'); - - // Extract file path from "diff --git a/path b/path" - const diffHeader = lines[0]; - if (!diffHeader) continue; - const pathMatch = diffHeader.match(/^diff --git a\/(.*?) b\/(.*)$/); - if (!pathMatch) continue; - // Use the "b/" path (post-image path) — more reliable for new/deleted files - const filePath = pathMatch[2]!.trim(); - - // Determine file status - let status: 'new' | 'deleted' | 'modified' = 'modified'; - if (trimmed.includes('new file mode')) status = 'new'; - if (trimmed.includes('deleted file mode')) status = 'deleted'; - - // For renamed files with 100% similarity (no content change), skip - if (trimmed.includes('similarity index 100%')) { - results.push({ path: filePath, status, added: 0, deleted: 0 }); - continue; - } - - // Locate the hunk section (starts with @@) - const hunkStart = trimmed.indexOf('@@ '); - if (hunkStart === -1) { - // Binary file or empty diff — no hunks - results.push({ path: filePath, status, added: 0, deleted: 0 }); - continue; - } - - const hunkSection = trimmed.substring(hunkStart); - const parsed = parseUnifiedDiff(hunkSection); - - let added = 0; - let deleted = 0; - for (const hunk of parsed.hunks) { - for (const line of hunk.lines) { - if (line.type === 'add') added++; - if (line.type === 'remove') deleted++; - } - } - - results.push({ path: filePath, status, added, deleted }); - } - - // Deduplicate by path (last wins for a given path) - const deduped = new Map(); - for (const r of results) { - deduped.set(r.path, r); - } - - return Array.from(deduped.values()); -} - -// ──────────────────────────────────────────────────────────────── -// Output formatting -// ──────────────────────────────────────────────────────────────── - -function formatDiffOutput(files: FileDiffInfo[]): string { - if (files.length === 0) { - return 'No changes to show.'; - } - - const totalAdded = files.reduce((s, f) => s + f.added, 0); - const totalDeleted = files.reduce((s, f) => s + f.deleted, 0); - - const lines: string[] = []; - lines.push(`GIT DIFF — ${files.length} file${files.length !== 1 ? 's' : ''} changed`); - - for (const f of files) { - const fileStr = f.path.padEnd(30); - const addStr = `+${f.added}`.padStart(5); - const delStr = `-${f.deleted}`.padStart(5); - - let statusStr: string; - switch (f.status) { - case 'new': - statusStr = '(new file)'; - break; - case 'deleted': - statusStr = '(deleted)'; - break; - default: - statusStr = '(modified)'; - } - - lines.push(` ${fileStr} ${addStr} ${delStr} ${statusStr}`); - } - - lines.push(''); - lines.push(`Total: +${totalAdded} -${totalDeleted}`); - - return lines.join('\n'); -} - -// ──────────────────────────────────────────────────────────────── -// Tool definition -// ──────────────────────────────────────────────────────────────── - -export const gitDiffTool = tool({ - description: - 'Get git diff as structured output. Saves ~90% tokens vs. bash → read → parse. Returns file-level summary with line counts. Complements apply_patch (produce diff → apply diff).', - - args: { - staged: tool.schema.boolean().describe('Show staged changes (git diff --staged)'), - file: tool.schema.string().describe('Specific file path to diff'), - from: tool.schema.string().describe('From commit/branch/ref'), - to: tool.schema.string().describe('To commit/branch/ref (defaults to HEAD if from is set)'), - }, - - async execute(args, ctx) { - const staged = args.staged as boolean | undefined; - const file = args.file as string | undefined; - const from = args.from as string | undefined; - const to = args.to as string | undefined; - const cwd = ctx.directory; - - logDebugEvent('git_diff.start', { staged, file, from, to, cwd }); - - try { - // Build git diff args - const gitArgs: string[] = ['diff']; - - if (staged) gitArgs.push('--staged'); - if (from) gitArgs.push(from); - if (to) gitArgs.push(to); - // Default: show changes against HEAD (working tree vs HEAD) - if (!from && !to) gitArgs.push('HEAD'); - if (file) { - gitArgs.push('--'); - gitArgs.push(file); - } - - const rawDiff = await runGit(gitArgs, cwd); - - if (!rawDiff.trim()) { - return 'No changes to show.'; - } - - const files = parseMultiFileDiff(rawDiff); - return formatDiffOutput(files); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('git_diff.error', { error: msg }); - return `Error getting diff: ${msg}`; - } - }, -}); - -// Exported for testing -export { parseMultiFileDiff, formatDiffOutput }; diff --git a/src/tools/git-log-structured.ts b/src/tools/git-log-structured.ts deleted file mode 100644 index 2552e5f..0000000 --- a/src/tools/git-log-structured.ts +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGit } from '../lib/git-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -interface LogEntry { - hash: string; - author: string; - date: string; - subject: string; - files?: FileStat[]; -} - -interface FileStat { - path: string; - added: number; - deleted: number; -} - -export const gitLogStructuredTool = tool({ - description: - 'Returns structured git log — filterable by author, date range, file pattern. Replaces multi-command bash pipelines that agents currently use.', - - args: { - count: tool.schema.number().describe('Number of commits to return (default: 20)'), - author: tool.schema.string().describe('Filter by author name'), - since: tool.schema.string().describe("Time filter (e.g., '2 weeks ago', '2024-01-01')"), - file: tool.schema.string().describe('Filter to commits touching this file'), - format: tool.schema.string().describe("Output format: 'summary' (default) or 'detailed'"), - }, - - async execute(args, ctx) { - const count = (args.count as number) ?? 20; - const author = args.author as string | undefined; - const since = args.since as string | undefined; - const file = args.file as string | undefined; - const format = (args.format as string) ?? 'summary'; - const cwd = ctx.directory; - - logDebugEvent('git_log_structured.start', { - count, - author: author ?? 'none', - since: since ?? 'none', - file: file ?? 'none', - format, - }); - - try { - // Build log command - const logArgs = ['log', '--format=%H|%an|%ai|%s', `-n${count}`]; - - if (author) { - logArgs.push(`--author=${author}`); - } - if (since) { - logArgs.push(`--since=${since}`); - } - if (file) { - logArgs.push('--', file); - } - - const logOutput = await runGit(logArgs, cwd); - const entries = parseLogOutput(logOutput); - - if (entries.length === 0) { - return 'GIT LOG — no commits found'; - } - - // For detailed format, fetch file stats for each commit - if (format === 'detailed') { - for (const entry of entries) { - try { - const statOutput = await runGit(['show', '--stat', '--format=', entry.hash], cwd); - entry.files = parseStatOutput(statOutput); - } catch { - entry.files = []; - } - } - } - - logDebugEvent('git_log_structured.done', { commits: entries.length, format }); - return formatLogOutput(entries, format); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('git_log_structured.error', { error: msg }); - return `Error fetching git log: ${msg}`; - } - }, -}); - -/** - * Parse the output of `git log --format='%H|%an|%ai|%s'`. - */ -function parseLogOutput(raw: string): LogEntry[] { - const entries: LogEntry[] = []; - const lines = raw.split('\n'); - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === '') continue; - - // Format: HASH|AUTHOR|DATE|SUBJECT - // The hash is 40 hex chars - const match = trimmed.match(/^([0-9a-f]{40})\|([^|]+)\|([^|]+)\|(.+)$/); - if (match) { - entries.push({ - hash: match[1]!, - author: match[2]!, - date: match[3]!, - subject: match[4]!, - }); - } - } - - return entries; -} - -/** - * Parse `git show --stat` output to extract file stats. - * Format: - * path/to/file.ts | 5 +++-- - * 2 files changed, 5 insertions(+), 3 deletions(-) - */ -export function parseStatOutput(raw: string): FileStat[] { - const files: FileStat[] = []; - const lines = raw.split('\n'); - - for (const line of lines) { - // Match: "path/to/file.ts | 5 +++--" - const match = line.match(/^\s*(.+?)\s*\|\s*(\d+)\s*([+-]*)$/); - if (match) { - const path = match[1]!.trim(); - const changes = parseInt(match[2]!, 10); - const plusMinus = match[3]!; - // Count + and - signs - let added = 0; - let deleted = 0; - for (const ch of plusMinus) { - if (ch === '+') added++; - else if (ch === '-') deleted++; - } - // If we can't determine from signs, use the total as added - if (added === 0 && deleted === 0) { - added = changes; - } - files.push({ path, added, deleted }); - } - // Also match: "path/to/file.ts | Bin 0 -> 1234 bytes" (binary) - else if (line.match(/^\s*(.+?)\s*\|\s*Bin/)) { - // Skip binary files - } - // Match: "path/to/file.ts" without | (new file with no changes shown) - // This is less common but can happen - } - - return files; -} - -/** - * Format log output as plain text. - */ -export function formatLogOutput(entries: LogEntry[], format: string): string { - if (format === 'detailed') { - return formatDetailedLog(entries); - } - return formatSummaryLog(entries); -} - -function formatSummaryLog(entries: LogEntry[]): string { - const lines: string[] = []; - lines.push(`GIT LOG — last ${entries.length} commit${entries.length !== 1 ? 's' : ''}`); - - for (const e of entries) { - const shortHash = e.hash.slice(0, 7); - const dateStr = e.date.slice(0, 10); // just the date part - lines.push(` ${shortHash} ${e.author.padEnd(10)} ${dateStr} ${e.subject}`); - } - - return lines.join('\n'); -} - -function formatDetailedLog(entries: LogEntry[]): string { - const lines: string[] = []; - lines.push('GIT LOG — detailed'); - - for (const e of entries) { - const shortHash = e.hash.slice(0, 7); - const dateStr = e.date.slice(0, 10); - lines.push(` ${shortHash} ${e.author.padEnd(10)} ${dateStr}`); - lines.push(` ${e.subject}`); - - if (e.files && e.files.length > 0) { - const fileParts = e.files.map((f) => `${f.path} (+${f.added}, -${f.deleted})`); - lines.push(` Files: ${fileParts.join(', ')}`); - } - - lines.push(''); // blank line between commits - } - - return lines.join('\n').trimEnd(); -} diff --git a/src/tools/gitlab-mr-comment.ts b/src/tools/gitlab-mr-comment.ts deleted file mode 100644 index 5245888..0000000 --- a/src/tools/gitlab-mr-comment.ts +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { getGitLabProjectId, gitlabApi } from '../lib/gitlab-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -export const gitlabMrCommentTool = tool({ - description: 'Add a comment to a GitLab merge request.', - - args: { - mrIid: tool.schema.number().describe('MR internal ID (!number)'), - body: tool.schema.string().describe('Comment text (markdown)'), - }, - - async execute(args, ctx) { - const { mrIid, body } = args; - - logDebugEvent('gitlab_mr_comment.start', { mrIid }); - - try { - const projectId = await getGitLabProjectId(ctx.directory); - if (!projectId) return 'Could not determine GitLab project ID.'; - - const result = await gitlabApi( - `projects/${projectId}/merge_requests/${mrIid}/notes`, - 'POST', - { body } - ); - - if (!result.ok) { - return `Failed to add comment: ${result.error}`; - } - - logDebugEvent('gitlab_mr_comment.success', { mrIid }); - return `Comment added to MR !${mrIid}.`; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return `Error: ${msg}`; - } - }, -}); diff --git a/src/tools/gitlab-mr-create.ts b/src/tools/gitlab-mr-create.ts deleted file mode 100644 index d850276..0000000 --- a/src/tools/gitlab-mr-create.ts +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { getGitLabProjectId, gitlabApi } from '../lib/gitlab-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -export const gitlabMrCreateTool = tool({ - description: 'Create a GitLab merge request. Uses GITLAB_TOKEN for authentication.', - - args: { - title: tool.schema.string().describe('MR title'), - sourceBranch: tool.schema.string().describe('Source branch name'), - targetBranch: tool.schema.string().optional().describe('Target branch (default: main)'), - description: tool.schema.string().optional().describe('MR description (markdown)'), - }, - - async execute(args, ctx) { - const { title, sourceBranch, targetBranch, description } = args; - - logDebugEvent('gitlab_mr_create.start', { title, sourceBranch, targetBranch }); - - try { - const projectId = await getGitLabProjectId(ctx.directory); - if (!projectId) return 'Could not determine GitLab project ID from git remote.'; - - const result = await gitlabApi(`projects/${projectId}/merge_requests`, 'POST', { - title, - source_branch: sourceBranch, - target_branch: targetBranch || 'main', - description: description || '', - }); - - if (!result.ok) { - return `Failed to create MR: ${result.error}`; - } - - const mr = result.data; - logDebugEvent('gitlab_mr_create.success', { iid: mr.iid, url: mr.web_url }); - return `MR !${mr.iid} created: ${mr.title}\n ${mr.web_url}`; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('gitlab_mr_create.error', { error: msg }); - return `Error: ${msg}`; - } - }, -}); diff --git a/src/tools/gitlab-mr-status.ts b/src/tools/gitlab-mr-status.ts deleted file mode 100644 index 252dbbb..0000000 --- a/src/tools/gitlab-mr-status.ts +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { getGitLabProjectId, gitlabApi } from '../lib/gitlab-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -export const gitlabMrStatusTool = tool({ - description: 'Check GitLab merge request status — state, mergeability, approvals, CI pipeline.', - - args: { - mrIid: tool.schema - .number() - .optional() - .describe('MR internal ID (!number). If omitted, lists all open MRs for the project.'), - }, - - async execute(args, ctx) { - const { mrIid } = args; - - logDebugEvent('gitlab_mr_status.start', { mrIid }); - - try { - const projectId = await getGitLabProjectId(ctx.directory); - if (!projectId) return 'Could not determine GitLab project ID.'; - - if (mrIid) { - // Single MR - const result = await gitlabApi(`projects/${projectId}/merge_requests/${mrIid}`); - - if (!result.ok) return `Failed to get MR: ${result.error}`; - - const mr = result.data; - const lines = [ - `MR !${mr.iid}: ${mr.title}`, - ` State: ${mr.state}`, - ` Mergeable: ${mr.merge_status}`, - ` Source: ${mr.source_branch} → ${mr.target_branch}`, - ` Author: ${mr.author?.name || 'unknown'}`, - mr.web_url ? ` URL: ${mr.web_url}` : '', - ]; - return lines.filter(Boolean).join('\n'); - } else { - // List open MRs - const result = await gitlabApi( - `projects/${projectId}/merge_requests?state=opened&per_page=10` - ); - - if (!result.ok) return `Failed to list MRs: ${result.error}`; - - const mrs = result.data as any[]; - if (!mrs || mrs.length === 0) return 'No open merge requests.'; - - const lines = [`${mrs.length} open MR(s):`]; - for (const mr of mrs) { - lines.push( - ` !${mr.iid}: ${mr.title} [${mr.merge_status}] (${mr.source_branch} → ${mr.target_branch})` - ); - } - return lines.join('\n'); - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return `Error: ${msg}`; - } - }, -}); diff --git a/src/tools/implicit-coupling.ts b/src/tools/implicit-coupling.ts deleted file mode 100644 index e9d232f..0000000 --- a/src/tools/implicit-coupling.ts +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { parseGitLog, type Commit } from '../lib/git-utils'; -import { logDebugEvent } from '../lib/debug-logger'; - -interface CouplingResult { - files: [string, string]; - coCommits: number; - couplingStrength: number; -} - -export const implicitCouplingTool = tool({ - description: - 'Detect files that always change together in the same commit — hidden dependencies invisible in code. Returns co-commit pairs ranked by coupling strength.', - - args: { - threshold: tool.schema - .number() - .describe('Minimum co-commit rate to report (0.0–1.0, default: 0.8)'), - since: tool.schema.string().describe("Only consider commits since date (e.g., '90d', '6m')"), - }, - - async execute(args, ctx) { - const threshold = (args.threshold as number) ?? 0.8; - const since = args.since as string | undefined; - const cwd = ctx.directory; - - logDebugEvent('implicit_coupling.start', { threshold, since: since ?? 'none' }); - - try { - const commits = await parseGitLog(cwd, since); - const results = computeCoupling(commits, threshold); - logDebugEvent('implicit_coupling.done', { pairs: results.length }); - return formatCouplingOutput(results, threshold); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('implicit_coupling.error', { error: msg }); - return `Error computing implicit coupling: ${msg}`; - } - }, -}); - -/** - * Compute implicit coupling between files. - * For each commit with ≥2 files, build all file pairs. - * Count co-occurrences and compute coupling strength. - * - * Optimization: for repos with >500 changed files, sample top 500 most-changed files. - */ -export function computeCoupling(commits: Commit[], threshold: number): CouplingResult[] { - // Build set of all files referenced in commits - const fileCounts = new Map(); - for (const commit of commits) { - for (const f of commit.files) { - fileCounts.set(f.path, (fileCounts.get(f.path) ?? 0) + 1); - } - } - - // Optimization: for repos with >500 changed files, sample top 500 most-changed files - const MAX_FILES = 500; - let sampledFiles: Set; - - if (fileCounts.size > MAX_FILES) { - const sorted = Array.from(fileCounts.entries()).sort((a, b) => b[1] - a[1]); - sampledFiles = new Set(sorted.slice(0, MAX_FILES).map(([path]) => path)); - } else { - sampledFiles = new Set(fileCounts.keys()); - } - - const pairCounts = new Map(); - const fileTotalCommits = new Map(); - - for (const commit of commits) { - // Filter files to sampled set - const changedFiles = commit.files.map((f) => f.path).filter((p) => sampledFiles.has(p)); - - if (changedFiles.length < 2) continue; - - // Track total commits per file - for (const file of changedFiles) { - fileTotalCommits.set(file, (fileTotalCommits.get(file) ?? 0) + 1); - } - - // Build all pairs - for (let i = 0; i < changedFiles.length; i++) { - for (let j = i + 1; j < changedFiles.length; j++) { - const a = changedFiles[i]!; - const b = changedFiles[j]!; - // Canonical ordering - const key = a < b ? `${a}|||${b}` : `${b}|||${a}`; - const existing = pairCounts.get(key); - if (existing) { - existing.count++; - } else { - pairCounts.set(key, { - count: 1, - maxA: fileTotalCommits.get(a) ?? 1, - maxB: fileTotalCommits.get(b) ?? 1, - }); - } - } - } - - // Update max counts for existing pairs - for (const [key, data] of pairCounts) { - const [fileA, fileB] = key.split('|||') as [string, string]; - data.maxA = fileTotalCommits.get(fileA) ?? data.maxA; - data.maxB = fileTotalCommits.get(fileB) ?? data.maxB; - } - } - - // Evaluate coupling strength - const results: CouplingResult[] = []; - for (const [key, data] of pairCounts) { - const [fileA, fileB] = key.split('|||') as [string, string]; - const maxCommits = Math.max(fileTotalCommits.get(fileA) ?? 0, fileTotalCommits.get(fileB) ?? 0); - if (maxCommits === 0) continue; - - const strength = Math.round((data.count / maxCommits) * 1000) / 1000; - - if (strength >= threshold) { - results.push({ - files: [fileA, fileB], - coCommits: data.count, - couplingStrength: strength, - }); - } - } - - results.sort((a, b) => b.couplingStrength - a.couplingStrength); - // Limit to top 50 pairs to avoid O(n²) output - return results.slice(0, 50); -} - -/** - * Format coupling results as plain text. - */ -function formatCouplingOutput(results: CouplingResult[], threshold: number): string { - if (results.length === 0) { - return `IMPLICIT COUPLING — no pairs meet the threshold of ${threshold}`; - } - - const lines: string[] = []; - lines.push(`IMPLICIT COUPLING — files that change together (threshold: ${threshold.toFixed(2)})`); - - for (const r of results) { - const fileA = r.files[0]; - const fileB = r.files[1]; - const strengthStr = r.couplingStrength.toFixed(2); - const coStr = `${r.coCommits} co-commit${r.coCommits !== 1 ? 's' : ''}`; - lines.push(` ${fileA} ↔ ${fileB} ${strengthStr} (${coStr})`); - } - - return lines.join('\n'); -} diff --git a/src/tools/ownership.ts b/src/tools/ownership.ts deleted file mode 100644 index a03b444..0000000 --- a/src/tools/ownership.ts +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { parseGitBlame, parseGitBlameForDir, type BlameLine } from '../lib/git-utils'; -import { statSync, existsSync } from 'node:fs'; -import { logDebugEvent } from '../lib/debug-logger'; -import { resolve } from 'node:path'; - -interface AuthorStat { - author: string; - lines: number; - pct: number; -} - -export const ownershipTool = tool({ - description: - 'Analyze who owns the lines alive in HEAD — per-file and per-directory author breakdown. Surfaces knowledge silos and onboarding targets.', - - args: { - path: tool.schema - .string() - .describe('File or directory path relative to repo root (default: entire repo)'), - }, - - async execute(args, ctx) { - const targetPath = (args.path as string) || '.'; - const cwd = ctx.directory; - - logDebugEvent('ownership.start', { path: targetPath }); - - try { - const result = await computeOwnership(targetPath, cwd); - logDebugEvent('ownership.done', {}); - return result; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('ownership.error', { error: msg }); - return `Error computing ownership: ${msg}`; - } - }, -}); - -/** - * Compute author breakdown for a file or directory. - * Returns formatted text output. - */ -export async function computeOwnership(targetPath: string, cwd: string): Promise { - const absolutePath = resolve(cwd, targetPath); - - if (!existsSync(absolutePath)) { - return `Path not found: ${targetPath}`; - } - - const isDir = statSync(absolutePath).isDirectory(); - const normalizedPath = targetPath === '.' ? targetPath : targetPath.replace(/\/$/, ''); - - if (isDir) { - return computeDirOwnership(normalizedPath, cwd); - } - - return computeFileOwnership(normalizedPath, cwd); -} - -/** - * Aggregate author stats from blame lines. - */ -function aggregateAuthors(blameLines: BlameLine[]): { total: number; authors: AuthorStat[] } { - const authorLines = new Map(); - - for (const bl of blameLines) { - if (bl.author && bl.author !== 'Not Committed Yet') { - authorLines.set(bl.author, (authorLines.get(bl.author) ?? 0) + 1); - } - } - - const total = Array.from(authorLines.values()).reduce((sum, n) => sum + n, 0); - const authors: AuthorStat[] = []; - - for (const [author, lines] of authorLines) { - authors.push({ - author, - lines, - pct: total > 0 ? Math.round((lines / total) * 1000) / 10 : 0, - }); - } - - authors.sort((a, b) => b.lines - a.lines); - - return { total, authors }; -} - -async function computeFileOwnership(filePath: string, cwd: string): Promise { - const blameLines = await parseGitBlame(filePath, cwd); - const { total, authors } = aggregateAuthors(blameLines); - - if (total === 0) { - return 'File has no lines'; - } - - const lines: string[] = []; - lines.push(`OWNERSHIP — ${filePath} (${total} lines)`); - - for (const a of authors) { - const isSilo = a.pct > 80; - const siloFlag = isSilo ? ' ⚠ KNOWLEDGE SILO' : ''; - lines.push( - ` ${a.author.padEnd(14)} ${a.lines.toString().padStart(5)} lines (${a.pct}%)${siloFlag}` - ); - } - - const topAuthor = authors[0]; - if (topAuthor && topAuthor.pct <= 80) { - lines.push(` ⚠ ${topAuthor.author} owns <80% — no knowledge silo`); - } - - return lines.join('\n'); -} - -async function computeDirOwnership(dirPath: string, cwd: string): Promise { - const blameMap = await parseGitBlameForDir(dirPath, cwd); - - if (blameMap.size === 0) { - return 'No source files in directory'; - } - - const globalAuthorLines = new Map(); - let globalTotal = 0; - let fileCount = 0; - - for (const [, blameLines] of blameMap) { - const { total, authors } = aggregateAuthors(blameLines); - for (const author of authors) { - globalAuthorLines.set( - author.author, - (globalAuthorLines.get(author.author) ?? 0) + author.lines - ); - } - globalTotal += total; - fileCount++; - } - - const globalAuthors: AuthorStat[] = []; - for (const [author, lines] of globalAuthorLines) { - globalAuthors.push({ - author, - lines, - pct: globalTotal > 0 ? Math.round((lines / globalTotal) * 1000) / 10 : 0, - }); - } - globalAuthors.sort((a, b) => b.lines - a.lines); - - const lines: string[] = []; - lines.push( - `OWNERSHIP — ${dirPath === '.' ? '.' : dirPath}/ (${globalTotal.toLocaleString()} lines across ${fileCount} files)` - ); - - for (const a of globalAuthors) { - const isSilo = a.pct > 80; - const siloFlag = isSilo ? ' ⚠ KNOWLEDGE SILO' : ''; - lines.push( - ` ${a.author.padEnd(12)} ${a.lines.toLocaleString().padStart(6)} lines (${a.pct}%)${siloFlag}` - ); - } - - return lines.join('\n'); -} diff --git a/src/tools/pr-risk.ts b/src/tools/pr-risk.ts deleted file mode 100644 index 5debc15..0000000 --- a/src/tools/pr-risk.ts +++ /dev/null @@ -1,232 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { runGit, parseGitLog } from '../lib/git-utils'; -import { computeCurseScores } from './curse-score'; -import { computeCoupling } from './implicit-coupling'; -import { logDebugEvent } from '../lib/debug-logger'; - -interface FileRisk { - file: string; - curseScore: number; - isTopDangerous: boolean; - isNew: boolean; - isTest: boolean; -} - -interface CouplingRisk { - fileA: string; - fileB: string; - coCommitRate: number; -} - -export const prRiskTool = tool({ - description: - 'Scores the risk of uncommitted changes (staged and unstaged). Analyzes curse scores, implicit coupling, and bus factor to surface hidden dangers in your current diff.', - - args: {}, - - async execute(_args, ctx) { - const cwd = ctx.directory; - - logDebugEvent('pr_risk.start', {}); - - try { - // 1. Get changed files (staged + unstaged) - const unstagedRaw = await runGit(['diff', '--name-only'], cwd); - const stagedRaw = await runGit(['diff', '--cached', '--name-only'], cwd); - - const unstagedFiles = unstagedRaw - .split('\n') - .map((f) => f.trim()) - .filter((f) => f !== ''); - const stagedFiles = stagedRaw - .split('\n') - .map((f) => f.trim()) - .filter((f) => f !== ''); - - const allChanged = Array.from(new Set([...unstagedFiles, ...stagedFiles])).sort(); - - if (allChanged.length === 0) { - return 'PR RISK — no changes to analyze'; - } - - // 2. Get curse scores for the repo (using recent history for relevance) - const commits = await parseGitLog(cwd, '90 days ago'); - const curseScores = computeCurseScores(commits, 100); - - // Build a map of file → curse score - const curseMap = new Map(); - curseScores.forEach((r, i) => { - curseMap.set(r.file, { score: r.score, rank: i + 1 }); - }); - - // 3. Compute implicit coupling between changed files - let couplingRisks: CouplingRisk[] = []; - if (allChanged.length >= 2) { - const allCoupling = computeCoupling(commits, 0); - couplingRisks = findCouplingBetween(allChanged, allCoupling); - } - - // 4. Build file risk assessments - const fileRisks: FileRisk[] = []; - for (const file of allChanged) { - const curseInfo = curseMap.get(file); - fileRisks.push({ - file, - curseScore: curseInfo?.score ?? 0, - isTopDangerous: curseInfo ? curseInfo.rank <= 3 : false, - isNew: !curseMap.has(file), - isTest: isTestFile(file), - }); - } - - // 5. Compute risk level - const totalCurse = fileRisks.reduce((sum, f) => sum + f.curseScore, 0); - const hasCouplingRisk = couplingRisks.some((c) => c.coCommitRate > 0.7); - const hasHighCoupling = couplingRisks.some((c) => c.coCommitRate > 0.8); - - let riskLevel: string; - if (totalCurse > 5000 && hasHighCoupling) { - riskLevel = 'CRITICAL'; - } else if (totalCurse > 2000 && hasCouplingRisk) { - riskLevel = 'HIGH'; - } else if (totalCurse > 500 || hasCouplingRisk) { - riskLevel = 'MEDIUM'; - } else { - riskLevel = 'LOW'; - } - - // 6. Check bus factor concern (author ownership from git log) - const authorChanges = new Map(); - let totalChanges = 0; - for (const commit of commits) { - for (const f of commit.files) { - if (allChanged.includes(f.path)) { - authorChanges.set(commit.author, (authorChanges.get(commit.author) ?? 0) + 1); - totalChanges++; - } - } - } - - let busFactorWarning = false; - let topAuthor = ''; - if (totalChanges > 0) { - for (const [author, changes] of authorChanges) { - const pct = changes / totalChanges; - if (pct > 0.7) { - busFactorWarning = true; - topAuthor = author; - break; - } - } - } - - const testOnly = fileRisks.length > 0 && fileRisks.every((f) => f.isTest); - - logDebugEvent('pr_risk.done', { - files: allChanged.length, - riskLevel, - totalCurse, - couplingPairs: couplingRisks.length, - }); - - return formatPrRiskOutput( - fileRisks, - couplingRisks, - riskLevel, - busFactorWarning, - topAuthor, - testOnly - ); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('pr_risk.error', { error: msg }); - return `Error computing PR risk: ${msg}`; - } - }, -}); - -/** - * Find coupling pairs between the changed files. - */ -function findCouplingBetween( - changedFiles: string[], - allCoupling: { files: [string, string]; couplingStrength: number }[] -): CouplingRisk[] { - const changedSet = new Set(changedFiles); - const result: CouplingRisk[] = []; - - for (const c of allCoupling) { - if (changedSet.has(c.files[0]) && changedSet.has(c.files[1])) { - result.push({ - fileA: c.files[0], - fileB: c.files[1], - coCommitRate: c.couplingStrength, - }); - } - } - - return result; -} - -/** - * Check if a file is a test file. - */ -function isTestFile(file: string): boolean { - return /(^|\/)tests?\//.test(file) || /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(file); -} - -/** - * Format PR risk output as plain text. - */ -export function formatPrRiskOutput( - fileRisks: FileRisk[], - couplingRisks: CouplingRisk[], - riskLevel: string, - busFactorWarning: boolean, - topAuthor: string, - testOnly: boolean -): string { - const lines: string[] = []; - - lines.push(`PR RISK — ${fileRisks.length} file${fileRisks.length !== 1 ? 's' : ''} changed`); - lines.push(` Risk level: ${riskLevel}`); - - if (testOnly) { - lines.push(' ℹ test-only changes, lower risk'); - } - - // File breakdown - lines.push(''); - lines.push(' Files:'); - for (const f of fileRisks) { - const danger = f.isTopDangerous ? ' ⚠ top 3 most dangerous file' : ''; - const newFlag = f.isNew ? ' [new file, no history to score]' : ''; - lines.push( - ` ${f.file.padEnd(30)} curse: ${String(f.curseScore).padStart(5)}${danger}${newFlag}` - ); - } - - // Coupling - if (couplingRisks.length > 0) { - lines.push(''); - lines.push(' Coupling:'); - for (const c of couplingRisks) { - const pct = Math.round(c.coCommitRate * 100); - lines.push(` ⚠ ${c.fileA} ↔ ${c.fileB} co-commit rate: ${c.coCommitRate.toFixed(2)}`); - lines.push( - ` → These files change together ${pct}% of the time. Consider reviewing both carefully.` - ); - } - } - - // Bus factor - if (busFactorWarning) { - lines.push(''); - lines.push(` ⚠ Low bus factor: ${topAuthor} owns >70% of these files`); - } - - return lines.join('\n'); -} diff --git a/src/tools/trend.ts b/src/tools/trend.ts deleted file mode 100644 index e338533..0000000 --- a/src/tools/trend.ts +++ /dev/null @@ -1,211 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { tool } from '@opencode-ai/plugin'; -import { parseGitLog } from '../lib/git-utils'; -import { computeCurseScores } from './curse-score'; -import { logDebugEvent } from '../lib/debug-logger'; - -interface TrendResult { - file: string; - recentScore: number; - olderScore: number; - delta: number; - note?: string; -} - -export const trendTool = tool({ - description: - 'Identifies files whose curse score is increasing over time — getting more dangerous, not stabilizing. Compares two time windows and returns files with positive trend.', - - args: { - top: tool.schema.number().describe('Number of files to return (default: 10)'), - window_days: tool.schema - .number() - .describe('Size of each comparison window in days (default: 90)'), - }, - - async execute(args, ctx) { - const top = (args.top as number) ?? 10; - const windowDays = (args.window_days as number) ?? 90; - const cwd = ctx.directory; - - logDebugEvent('trend.start', { top, window_days: windowDays }); - - try { - // Recent window: last N days - const recentCommits = await parseGitLog(cwd, `${windowDays} days ago`); - - // Older window: N to 2N days ago - const olderCommits = await parseGitLog( - cwd, - `${windowDays * 2} days ago`, - `${windowDays} days ago` - ); - - const results = computeTrend(recentCommits, olderCommits, windowDays, top); - logDebugEvent('trend.done', { - worsening: results.worsening.length, - improving: results.improving.length, - }); - return formatTrendOutput(results, windowDays, top); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logDebugEvent('trend.error', { error: msg }); - return `Error computing curse score trends: ${msg}`; - } - }, -}); - -/** - * Compute trend by comparing curse scores between two time windows. - */ -export function computeTrend( - recentCommits: import('../lib/git-utils').Commit[], - olderCommits: import('../lib/git-utils').Commit[], - windowDays: number, - top: number -): { worsening: TrendResult[]; improving: TrendResult[]; insufficientHistory: boolean } { - // Check for insufficient history - if (olderCommits.length === 0) { - // If we have recent but no older, note insufficient history - if (recentCommits.length > 0) { - return { worsening: [], improving: [], insufficientHistory: true }; - } - return { worsening: [], improving: [], insufficientHistory: false }; - } - - // Compute curse scores for each window — get ALL files, not just top N - // Use the end of each window as the reference date for curse score calculation - const recentRefDate = new Date(); // now - const olderRefDate = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); // end of older window - - const recentScores = computeCurseScoresAll(recentCommits, recentRefDate); - const olderScores = computeCurseScoresAll(olderCommits, olderRefDate); - - // Build map for easy lookup - const olderMap = new Map(); - for (const r of olderScores) { - olderMap.set(r.file, r.score); - } - - // Track all files we've seen - const seenFiles = new Set(); - const results: TrendResult[] = []; - - for (const r of recentScores) { - seenFiles.add(r.file); - const olderScore = olderMap.get(r.file) ?? 0; - const delta = Math.round((r.score - olderScore) * 10) / 10; - - let note: string | undefined; - if (!olderMap.has(r.file)) { - note = 'new file, no older score'; - } - - results.push({ - file: r.file, - recentScore: r.score, - olderScore, - delta, - note, - }); - } - - // Check for files that existed in the older window but NOT in recent (deleted) - for (const [file, olderScore] of olderMap) { - if (!seenFiles.has(file)) { - results.push({ - file, - recentScore: 0, - olderScore, - delta: -olderScore, - note: 'deleted', - }); - } - } - - // Sort by delta descending (worsening first) - results.sort((a, b) => b.delta - a.delta); - - const worsening = results.filter((r) => r.delta > 0).slice(0, top); - const improving = results - .filter((r) => r.delta < 0) - .sort((a, b) => a.delta - b.delta) - .slice(0, top); - - return { worsening, improving, insufficientHistory: false }; -} - -/** - * Compute curse scores for ALL files (not just top N). - * Reuses the same algorithm from curse-score.ts but returns all results. - */ -function computeCurseScoresAll( - commits: import('../lib/git-utils').Commit[], - referenceDate?: Date -): { file: string; score: number }[] { - // computeCurseScores with a very large topN returns all sorted results - return computeCurseScores(commits, 10000, referenceDate).map((r) => ({ - file: r.file, - score: r.score, - })); -} - -/** - * Format trend output as plain text. - */ -export function formatTrendOutput( - result: { worsening: TrendResult[]; improving: TrendResult[]; insufficientHistory: boolean }, - windowDays: number, - _top: number -): string { - const lines: string[] = []; - - if (result.insufficientHistory) { - lines.push(`TREND — insufficient history for trend (need >${windowDays * 2}d of git history)`); - lines.push(''); - lines.push('Consider using `curse_score` for a single-window analysis instead.'); - return lines.join('\n'); - } - - if (result.worsening.length === 0 && result.improving.length === 0) { - lines.push('TREND — no significant changes detected'); - return lines.join('\n'); - } - - lines.push(`TREND — files getting more dangerous (${windowDays}d windows)`); - - for (let i = 0; i < result.worsening.length; i++) { - const r = result.worsening[i]!; - const rank = (i + 1).toString().padStart(3, ' '); - const note = r.note ? ` [${r.note}]` : ''; - const recentStr = String(Math.round(r.recentScore)); - const olderStr = String(Math.round(r.olderScore)); - const deltaStr = r.delta >= 0 ? `+${Math.round(r.delta)}` : `${Math.round(r.delta)}`; - const filePad = r.file.padEnd(35); - lines.push( - ` ${rank}. ${filePad} recent: ${recentStr.padStart(5)} older: ${olderStr.padStart(5)} Δ ${deltaStr}${note}` - ); - } - - if (result.improving.length > 0) { - lines.push(''); - lines.push(' Improving:'); - for (let i = 0; i < result.improving.length; i++) { - const r = result.improving[i]!; - const rank = (result.worsening.length + i + 1).toString() + '.'; - const rankPad = rank.padEnd(4); - const note = r.note ? ` [${r.note}]` : ''; - const recentStr = String(Math.round(r.recentScore)); - const olderStr = String(Math.round(r.olderScore)); - const deltaStr = `${Math.round(r.delta)}`; - const filePad = r.file.padEnd(35); - lines.push( - ` ${rankPad} ${filePad} recent: ${recentStr.padStart(5)} older: ${olderStr.padStart(5)} Δ ${deltaStr}${note}` - ); - } - } - - return lines.join('\n'); -} diff --git a/tests/apply-patch.test.ts b/tests/apply-patch.test.ts deleted file mode 100644 index 60cc962..0000000 --- a/tests/apply-patch.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -// Test the tool's execute function directly -// (We import the raw execute function by accessing it from the tool definition) -import { applyPatchTool } from '../src/tools/apply-patch'; - -// Create a mock ToolContext -function mockCtx(dir: string) { - return { - sessionID: 'test-session', - messageID: 'test-message', - agent: 'test-agent', - directory: dir, - worktree: dir, - abort: new AbortController().signal, - metadata: () => {}, - ask: async () => {}, - }; -} - -describe('patch_file tool', () => { - let testDir: string; - let ctx: ReturnType; - - beforeEach(() => { - testDir = join(tmpdir(), `supertools-test-${Date.now()}`); - mkdirSync(testDir, { recursive: true }); - ctx = mockCtx(testDir); - }); - - afterEach(() => { - try { - rmSync(testDir, { recursive: true }); - } catch { - /* ignore */ - } - }); - - it('patches an existing file', async () => { - const filePath = join(testDir, 'test.txt'); - writeFileSync(filePath, 'line1\nline2\nline3\n', 'utf-8'); - - const patch = `@@ -1,3 +1,3 @@ - line1 --line2 -+replaced - line3`; - - const result = await applyPatchTool.execute({ file_path: filePath, patch }, ctx); - expect(result).toContain('Successfully patched'); - - const updated = readFileSync(filePath, 'utf-8'); - expect(updated).toBe('line1\nreplaced\nline3\n'); - }); - - it('adds lines to an existing file', async () => { - const filePath = join(testDir, 'test.txt'); - writeFileSync(filePath, 'line1\nline2\n', 'utf-8'); - - const patch = `@@ -1,2 +1,3 @@ - line1 -+inserted - line2`; - - const result = await applyPatchTool.execute({ file_path: filePath, patch }, ctx); - expect(result).toContain('Successfully patched'); - - const updated = readFileSync(filePath, 'utf-8'); - expect(updated).toBe('line1\ninserted\nline2\n'); - }); - - it('creates a new file', async () => { - const filePath = join(testDir, 'newfile.txt'); - - const patch = `@@ -0,0 +1,2 @@ -+hello -+world`; - - const result = await applyPatchTool.execute({ file_path: filePath, patch }, ctx); - expect(result).toContain('Created new file'); - expect(existsSync(filePath)).toBe(true); - expect(readFileSync(filePath, 'utf-8')).toBe('hello\nworld\n'); - }); - - it('rejects malformed patches', async () => { - const filePath = join(testDir, 'test.txt'); - writeFileSync(filePath, 'content\n', 'utf-8'); - - const result = await applyPatchTool.execute( - { file_path: filePath, patch: 'not a real patch' }, - ctx - ); - expect(result).toContain('Error'); - }); - - it('rejects patches with mismatched context', async () => { - const filePath = join(testDir, 'test.txt'); - writeFileSync(filePath, 'completely different content\n', 'utf-8'); - - const patch = `@@ -1,1 +1,1 @@ --wrong context -+something`; - - const result = await applyPatchTool.execute({ file_path: filePath, patch }, ctx); - expect(result).toContain('Error'); - expect(result).toContain('Context mismatch'); - }); -}); diff --git a/tests/blast-radius.test.ts b/tests/blast-radius.test.ts deleted file mode 100644 index 35316fd..0000000 --- a/tests/blast-radius.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test'; -import { tmpdir } from 'node:os'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -// Mock runGit to return controlled git log and blame output -const runGitMock = mock(); - -mock.module('../src/lib/git-utils', () => { - const real = require('../src/lib/git-utils'); - return { - ...real, - runGit: runGitMock, - parseGitLog: async (cwd: string, since?: string) => { - const args = ['log', '--numstat', '--format=%H|%an|%aI']; - if (since) args.push(`--since=${since}`); - const output = await runGitMock(args, cwd); - return real.parseLogOutput(output); - }, - parseGitBlame: async (filePath: string, cwd?: string) => { - const workDir = cwd ?? process.cwd(); - try { - const output = await runGitMock(['blame', '--line-porcelain', '--', filePath], workDir); - return real.parseBlameOutput(output); - } catch (err: any) { - if ( - err.message.includes('no such path') || - err.message.includes('exists on disk, but not in') - ) { - return []; - } - throw err; - } - }, - }; -}); - -import { computeBlastRadius } from '../src/tools/blast-radius'; - -function buildLogOutput( - commits: { - hash: string; - author: string; - date: string; - files: { path: string; added: number; deleted: number }[]; - }[] -): string { - const lines: string[] = []; - for (const c of commits) { - lines.push(`${c.hash}|${c.author}|${c.date}`); - lines.push(''); - for (const f of c.files) { - lines.push(`${f.added}\t${f.deleted}\t${f.path}`); - } - } - return lines.join('\n'); -} - -describe('computeBlastRadius', () => { - let testDir: string; - - beforeEach(() => { - testDir = join(tmpdir(), `blast-test-${Date.now()}`); - mkdirSync(testDir, { recursive: true }); - writeFileSync(join(testDir, 'target.ts'), '// target file\n'); - }); - - afterEach(() => { - try { - rmSync(testDir, { recursive: true }); - } catch { - /* ignore */ - } - }); - - it('finds coupled files for target', async () => { - const logOutput = buildLogOutput([ - { - hash: 'a'.repeat(40), - author: 'alice', - date: new Date().toISOString(), - files: [ - { path: 'src/core/handler.ts', added: 10, deleted: 0 }, - { path: 'src/core/middleware.ts', added: 5, deleted: 0 }, - ], - }, - { - hash: 'b'.repeat(40), - author: 'alice', - date: new Date().toISOString(), - files: [ - { path: 'src/core/handler.ts', added: 3, deleted: 0 }, - { path: 'src/core/middleware.ts', added: 2, deleted: 0 }, - ], - }, - ]); - - // Mock blame for dominant author - const blameOutput = [ - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 1', - 'author alice', - '\tcode', - ].join('\n'); - - runGitMock.mockImplementation(async (args: string[]) => { - const cmd = args[0]; - if (cmd === 'blame') return blameOutput; - if (cmd === 'log') return logOutput; - return ''; - }); - - const result = await computeBlastRadius('src/core/handler.ts', testDir); - expect(result).toContain('BLAST RADIUS — src/core/handler.ts'); - expect(result).toContain('src/core/middleware.ts'); - expect(result).toContain('coupling'); - }); - - it('finds shared-author files', async () => { - // Two separate commits by the same author touching different files - const logOutput = buildLogOutput([ - { - hash: 'a'.repeat(40), - author: 'dougwilson', - date: new Date().toISOString(), - files: [{ path: 'src/core/handler.ts', added: 10, deleted: 0 }], - }, - { - hash: 'b'.repeat(40), - author: 'dougwilson', - date: new Date().toISOString(), - files: [{ path: 'lib/response.ts', added: 5, deleted: 0 }], - }, - ]); - - const blameOutput = [ - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 2', - 'author dougwilson', - '\tcode', - '\tmore code', - ].join('\n'); - - runGitMock.mockImplementation(async (args: string[]) => { - const cmd = args[0]; - if (cmd === 'blame') return blameOutput; - if (cmd === 'log') return logOutput; - return ''; - }); - - const result = await computeBlastRadius('src/core/handler.ts', testDir); - expect(result).toContain('BLAST RADIUS — src/core/handler.ts'); - expect(result).toContain('shared author'); - expect(result).toContain('dougwilson'); - }); - - it('finds same-directory files', async () => { - const logOutput = buildLogOutput([ - { - hash: 'a'.repeat(40), - author: 'alice', - date: new Date().toISOString(), // recent - files: [ - { path: 'src/core/handler.ts', added: 10, deleted: 0 }, - { path: 'src/core/validator.ts', added: 5, deleted: 0 }, - ], - }, - ]); - - const blameOutput = [ - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 1', - 'author alice', - '\tcode', - ].join('\n'); - - runGitMock.mockImplementation(async (args: string[]) => { - const cmd = args[0]; - if (cmd === 'blame') return blameOutput; - if (cmd === 'log') return logOutput; - return ''; - }); - - const result = await computeBlastRadius('src/core/handler.ts', testDir); - expect(result).toContain('BLAST RADIUS — src/core/handler.ts'); - expect(result).toContain('src/core/validator.ts'); - expect(result).toContain('same directory'); - }); - - it('returns error for file not in git history', async () => { - const logOutput = buildLogOutput([ - { - hash: 'a'.repeat(40), - author: 'alice', - date: new Date().toISOString(), - files: [{ path: 'other/file.ts', added: 1, deleted: 0 }], - }, - ]); - - runGitMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'log') return logOutput; - return ''; - }); - - const result = await computeBlastRadius('nonexistent-file.ts', testDir); - expect(result).toContain('File not found in git history'); - }); - - it('sorts by risk score descending', async () => { - const logOutput = buildLogOutput([ - // handler + middleware always together (strong coupling) - { - hash: 'a'.repeat(40), - author: 'alice', - date: new Date().toISOString(), - files: [ - { path: 'src/core/handler.ts', added: 10, deleted: 0 }, - { path: 'src/core/middleware.ts', added: 5, deleted: 0 }, - ], - }, - { - hash: 'b'.repeat(40), - author: 'alice', - date: new Date().toISOString(), - files: [ - { path: 'src/core/handler.ts', added: 3, deleted: 0 }, - { path: 'src/core/middleware.ts', added: 2, deleted: 0 }, - ], - }, - // handler + router sometimes together (weaker coupling) - { - hash: 'c'.repeat(40), - author: 'alice', - date: new Date().toISOString(), - files: [ - { path: 'src/core/handler.ts', added: 1, deleted: 0 }, - { path: 'src/core/router.ts', added: 1, deleted: 0 }, - ], - }, - { - hash: 'd'.repeat(40), - author: 'alice', - date: new Date().toISOString(), - files: [{ path: 'src/core/handler.ts', added: 2, deleted: 0 }], - }, - { - hash: 'e'.repeat(40), - author: 'alice', - date: new Date().toISOString(), - files: [{ path: 'src/core/router.ts', added: 1, deleted: 0 }], - }, - ]); - - const blameOutput = [ - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 1', - 'author alice', - '\tcode', - ].join('\n'); - - runGitMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'blame') return blameOutput; - if (args[0] === 'log') return logOutput; - return ''; - }); - - const result = await computeBlastRadius('src/core/handler.ts', testDir); - // middleware.ts should appear before router.ts - const middlewareIdx = result.indexOf('middleware.ts'); - const routerIdx = result.indexOf('router.ts'); - expect(middlewareIdx).toBeGreaterThan(0); - expect(routerIdx).toBeGreaterThan(0); - expect(middlewareIdx).toBeLessThan(routerIdx); - }); -}); diff --git a/tests/bus-factor.test.ts b/tests/bus-factor.test.ts deleted file mode 100644 index 77d419f..0000000 --- a/tests/bus-factor.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect } from 'bun:test'; -import { computeBusFactorFromLog } from '../src/tools/bus-factor'; -import type { Commit } from '../src/lib/git-utils'; - -function makeCommit( - hash: string, - author: string, - files: { path: string; added: number; deleted: number }[] -): Commit { - return { hash, author, date: '2024-01-15T00:00:00Z', files }; -} - -describe('computeBusFactorFromLog', () => { - it('returns empty array for no commits', () => { - const results = computeBusFactorFromLog([]); - expect(results).toEqual([]); - }); - - it('detects bus factor 1 when one author dominates >70%', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'alice', [{ path: 'src/core/a.ts', added: 10, deleted: 0 }]), - makeCommit('b'.repeat(40), 'alice', [{ path: 'src/core/b.ts', added: 5, deleted: 0 }]), - makeCommit('c'.repeat(40), 'alice', [{ path: 'src/core/c.ts', added: 3, deleted: 0 }]), - makeCommit('d'.repeat(40), 'alice', [{ path: 'src/core/d.ts', added: 8, deleted: 0 }]), - makeCommit('e'.repeat(40), 'bob', [{ path: 'src/core/e.ts', added: 2, deleted: 0 }]), - makeCommit('f'.repeat(40), 'alice', [{ path: 'src/core/f.ts', added: 1, deleted: 0 }]), - ]; - - const results = computeBusFactorFromLog(commits); - const coreDir = results.find((r) => r.dir === 'src'); - expect(coreDir).toBeDefined(); - // Total changes for src/ = 6, alice = 5 => 83.3% > 70% => bus factor 1 - expect(coreDir!.busFactor).toBe(1); - expect(coreDir!.topAuthor).toBe('alice'); - expect(coreDir!.topAuthorPct).toBeGreaterThan(70); - }); - - it('detects bus factor 2 when top author >50% but ≤70%', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'alice', [{ path: 'tests/a.test.ts', added: 10, deleted: 0 }]), - makeCommit('b'.repeat(40), 'bob', [{ path: 'tests/b.test.ts', added: 5, deleted: 0 }]), - makeCommit('c'.repeat(40), 'alice', [{ path: 'tests/c.test.ts', added: 3, deleted: 0 }]), - makeCommit('d'.repeat(40), 'bob', [{ path: 'tests/d.test.ts', added: 5, deleted: 0 }]), - makeCommit('e'.repeat(40), 'alice', [{ path: 'tests/e.test.ts', added: 4, deleted: 0 }]), - ]; - - const results = computeBusFactorFromLog(commits); - const testsDir = results.find((r) => r.dir === 'tests'); - expect(testsDir).toBeDefined(); - // alice: 3 changes, bob: 2 changes, total: 5 - // alice = 60% → between 50% and 70% → bus factor 2 - expect(testsDir!.busFactor).toBe(2); - expect(testsDir!.topAuthorPct).toBeGreaterThan(50); - expect(testsDir!.topAuthorPct).toBeLessThanOrEqual(70); - }); - - it('detects bus factor 3+ when ownership is distributed', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'alice', [{ path: 'docs/a.md', added: 10, deleted: 0 }]), - makeCommit('b'.repeat(40), 'bob', [{ path: 'docs/b.md', added: 5, deleted: 0 }]), - makeCommit('c'.repeat(40), 'charlie', [{ path: 'docs/c.md', added: 5, deleted: 0 }]), - makeCommit('d'.repeat(40), 'dave', [{ path: 'docs/d.md', added: 5, deleted: 0 }]), - makeCommit('e'.repeat(40), 'alice', [{ path: 'docs/e.md', added: 3, deleted: 0 }]), - makeCommit('f'.repeat(40), 'bob', [{ path: 'docs/f.md', added: 3, deleted: 0 }]), - ]; - - const results = computeBusFactorFromLog(commits); - const docsDir = results.find((r) => r.dir === 'docs'); - expect(docsDir).toBeDefined(); - // alice: 2, bob: 2, charlie: 1, dave: 1, total: 6 - // top = 33.3% → ≤50% → bus factor 3+ - expect(docsDir!.busFactor).toBeGreaterThanOrEqual(3); - }); - - it('skips directories with fewer than 5 commits', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'alice', [{ path: 'tiny/file.ts', added: 5, deleted: 0 }]), - ]; - - const results = computeBusFactorFromLog(commits); - // tiny/ has 1 commit → < 5 → should be skipped - const tinyDir = results.find((r) => r.dir === 'tiny'); - expect(tinyDir).toBeUndefined(); - }); - - it('handles multiple directories', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'alice', [{ path: 'src/a.ts', added: 10, deleted: 0 }]), - makeCommit('b'.repeat(40), 'alice', [{ path: 'src/b.ts', added: 5, deleted: 0 }]), - makeCommit('c'.repeat(40), 'alice', [{ path: 'src/c.ts', added: 3, deleted: 0 }]), - makeCommit('d'.repeat(40), 'alice', [{ path: 'src/d.ts', added: 5, deleted: 0 }]), - makeCommit('e'.repeat(40), 'bob', [{ path: 'src/e.ts', added: 2, deleted: 0 }]), - makeCommit('f'.repeat(40), 'bob', [{ path: 'lib/x.ts', added: 8, deleted: 0 }]), - makeCommit('g'.repeat(40), 'bob', [{ path: 'lib/y.ts', added: 5, deleted: 0 }]), - makeCommit('h'.repeat(40), 'bob', [{ path: 'lib/z.ts', added: 5, deleted: 0 }]), - makeCommit('i'.repeat(40), 'alice', [{ path: 'lib/w.ts', added: 3, deleted: 0 }]), - makeCommit('j'.repeat(40), 'bob', [{ path: 'lib/v.ts', added: 2, deleted: 0 }]), - ]; - - const results = computeBusFactorFromLog(commits); - expect(results.length).toBeGreaterThanOrEqual(2); - const dirs = results.map((r) => r.dir); - expect(dirs).toContain('src'); - expect(dirs).toContain('lib'); - }); - - it('sorts by bus factor (worst first)', () => { - const commits: Commit[] = [ - // lib/ dominated by bob (>70%) - makeCommit('a'.repeat(40), 'bob', [{ path: 'lib/a.ts', added: 10, deleted: 0 }]), - makeCommit('b'.repeat(40), 'bob', [{ path: 'lib/b.ts', added: 5, deleted: 0 }]), - makeCommit('c'.repeat(40), 'bob', [{ path: 'lib/c.ts', added: 5, deleted: 0 }]), - makeCommit('d'.repeat(40), 'alice', [{ path: 'lib/d.ts', added: 1, deleted: 0 }]), - makeCommit('e'.repeat(40), 'bob', [{ path: 'lib/e.ts', added: 5, deleted: 0 }]), - // src/ shared more evenly - makeCommit('f'.repeat(40), 'alice', [{ path: 'src/x.ts', added: 5, deleted: 0 }]), - makeCommit('g'.repeat(40), 'bob', [{ path: 'src/y.ts', added: 4, deleted: 0 }]), - makeCommit('h'.repeat(40), 'alice', [{ path: 'src/z.ts', added: 5, deleted: 0 }]), - makeCommit('i'.repeat(40), 'bob', [{ path: 'src/w.ts', added: 4, deleted: 0 }]), - makeCommit('j'.repeat(40), 'alice', [{ path: 'src/v.ts', added: 5, deleted: 0 }]), - ]; - - const results = computeBusFactorFromLog(commits); - expect(results.length).toBe(2); - // lib/ should come first (bus factor 1) before src/ (bus factor 2 or 3+) - expect(results[0].dir).toBe('lib'); - expect(results[0].busFactor).toBe(1); - }); -}); diff --git a/tests/curse-score.test.ts b/tests/curse-score.test.ts deleted file mode 100644 index 91ef9d6..0000000 --- a/tests/curse-score.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect } from 'bun:test'; -import { computeCurseScores } from '../src/tools/curse-score'; -import type { Commit } from '../src/lib/git-utils'; - -// Helper: create a commit with given files -function makeCommit( - hash: string, - author: string, - date: string, - files: { path: string; added: number; deleted: number }[] -): Commit { - return { hash, author, date, files }; -} - -describe('computeCurseScores', () => { - it('returns empty array for no commits', () => { - const results = computeCurseScores([], 10); - expect(results).toEqual([]); - }); - - it('scores a single file with one commit', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', new Date().toISOString(), [ - { path: 'src/main.ts', added: 10, deleted: 0 }, - ]), - ]; - - const results = computeCurseScores(commits, 10); - expect(results).toHaveLength(1); - expect(results[0].file).toBe('src/main.ts'); - expect(results[0].changes).toBe(1); - expect(results[0].authors).toBe(1); - expect(results[0].score).toBeGreaterThan(0); - expect(results[0].churnRate).toBeGreaterThan(0); - }); - - it('higher score for files with more authors', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', new Date().toISOString(), [ - { path: 'src/hot.ts', added: 10, deleted: 0 }, - { path: 'src/cold.ts', added: 1, deleted: 0 }, - ]), - makeCommit('b'.repeat(40), 'Bob', new Date().toISOString(), [ - { path: 'src/hot.ts', added: 5, deleted: 0 }, - ]), - makeCommit('c'.repeat(40), 'Charlie', new Date().toISOString(), [ - { path: 'src/hot.ts', added: 2, deleted: 0 }, - ]), - ]; - - const results = computeCurseScores(commits, 10); - // hot.ts has 3 changes by 3 authors, cold.ts has 1 change by 1 author - expect(results[0].file).toBe('src/hot.ts'); - expect(results[0].changes).toBe(3); - expect(results[0].authors).toBe(3); - expect(results[0].score).toBeGreaterThan(results[1].score); - }); - - it('older files score lower due to age decay', () => { - const now = new Date(); - const oldDate = '2020-01-01T00:00:00Z'; - - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', now.toISOString(), [ - { path: 'src/recent.ts', added: 10, deleted: 0 }, - ]), - makeCommit('b'.repeat(40), 'Alice', oldDate, [ - { path: 'src/ancient.ts', added: 10, deleted: 0 }, - ]), - ]; - - const results = computeCurseScores(commits, 10); - // recent.ts should score higher despite same changes/authors count (age decay) - const recent = results.find((r) => r.file === 'src/recent.ts'); - const ancient = results.find((r) => r.file === 'src/ancient.ts'); - expect(recent).toBeDefined(); - expect(ancient).toBeDefined(); - expect(recent!.score).toBeGreaterThan(ancient!.score); - }); - - it('acceleration boosts files with recent activity', () => { - const now = new Date(); - const oldDate = '2024-06-01T00:00:00Z'; - - // File A: 2 changes both recent — high acceleration - // File B: 2 changes both old — low acceleration - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', now.toISOString(), [ - { path: 'src/hot.ts', added: 10, deleted: 0 }, - ]), - makeCommit('b'.repeat(40), 'Alice', now.toISOString(), [ - { path: 'src/hot.ts', added: 5, deleted: 0 }, - ]), - makeCommit('c'.repeat(40), 'Alice', oldDate, [{ path: 'src/cold.ts', added: 5, deleted: 0 }]), - makeCommit('d'.repeat(40), 'Alice', oldDate, [{ path: 'src/cold.ts', added: 5, deleted: 0 }]), - ]; - - const results = computeCurseScores(commits, 10); - const hot = results.find((r) => r.file === 'src/hot.ts'); - const cold = results.find((r) => r.file === 'src/cold.ts'); - expect(hot).toBeDefined(); - expect(cold).toBeDefined(); - // hot should score higher because of acceleration - expect(hot!.score).toBeGreaterThan(cold!.score); - }); - - it('respects topN parameter', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', new Date().toISOString(), [ - { path: 'src/a.ts', added: 10, deleted: 0 }, - { path: 'src/b.ts', added: 5, deleted: 0 }, - { path: 'src/c.ts', added: 3, deleted: 0 }, - ]), - ]; - - const results = computeCurseScores(commits, 1); - expect(results).toHaveLength(1); - expect(results[0].file).toBe('src/a.ts'); - }); - - it('handles single author correctly (no division by zero)', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', new Date().toISOString(), [ - { path: 'src/solo.ts', added: 5, deleted: 0 }, - ]), - makeCommit('b'.repeat(40), 'Alice', new Date().toISOString(), [ - { path: 'src/solo.ts', added: 3, deleted: 0 }, - ]), - ]; - - const results = computeCurseScores(commits, 10); - expect(results).toHaveLength(1); - expect(results[0].authors).toBe(1); - expect(results[0].score).toBeGreaterThan(0); - // log₂(1+1) = log₂(2) = 1, so score = changes × 1 × exp + log(churn) × accel - }); - - it('skips binary files (added=0, deleted=0 via excluded patterns)', () => { - // isExcluded doesn't catch binary files directly, but the spec says skip them - // by the isExcluded filter in getFileList - in computeCurseScores we call isExcluded manually - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', new Date().toISOString(), [ - { path: 'assets/logo.png', added: 0, deleted: 0 }, - { path: 'src/main.ts', added: 10, deleted: 0 }, - ]), - ]; - - const results = computeCurseScores(commits, 10); - // logo.png may be included if not matched by isExcluded patterns - // The main check is that src/main.ts is present - expect(results.some((r) => r.file === 'src/main.ts')).toBe(true); - }); -}); diff --git a/tests/gh-bot-review.test.ts b/tests/gh-bot-review.test.ts deleted file mode 100644 index bcc1c75..0000000 --- a/tests/gh-bot-review.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect } from 'bun:test'; -import { - parseCodeRabbit, - parseCubicDev, - parseDependabot, - parseBotContent, - type BotFinding, -} from '../src/tools/gh-bot-review'; - -// ──────────────────────────────────────────────────────────────── -// Unit tests — parseCodeRabbit -// ──────────────────────────────────────────────────────────────── - -describe('parseCodeRabbit', () => { - it('parses single inline finding', () => { - const body = [ - 'In `@src/tools/foo.ts`:', - '- Line 35: Unused variable `x`. Consider removing it.', - ].join('\n'); - - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.bot).toBe('coderabbitai'); - expect(findings[0]!.file).toBe('src/tools/foo.ts'); - expect(findings[0]!.line).toBe(35); - expect(findings[0]!.description).toBe('Unused variable `x`. Consider removing it.'); - expect(findings[0]!.type).toBe('nitpick'); - expect(findings[0]!.severity).toBe('nitpick'); - expect(findings[0]!.actionable).toBe(false); - }); - - it('parses multiple inline findings across files', () => { - const body = [ - 'In `@src/tools/foo.ts`:', - '- Line 35: Unused variable `x`. Consider removing it.', - '- Line 42: Missing return type on function `bar`.', - '', - 'In `@src/tools/baz.ts`:', - '- Line 10: Hardcoded string literal. Consider using a constant.', - '', - 'Summary: 3 issues found.', - ].join('\n'); - - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(3); - expect(findings[0]!.file).toBe('src/tools/foo.ts'); - expect(findings[0]!.line).toBe(35); - expect(findings[1]!.file).toBe('src/tools/foo.ts'); - expect(findings[1]!.line).toBe(42); - expect(findings[2]!.file).toBe('src/tools/baz.ts'); - expect(findings[2]!.line).toBe(10); - }); - - it('classifies peer_dependency type correctly', () => { - const body = [ - 'In `@package.json`:', - '- Line 1: Potential peer dependency issue. The version mismatch could cause runtime errors.', - ].join('\n'); - - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.type).toBe('peer_dependency'); - expect(findings[0]!.severity).toBe('P1'); - expect(findings[0]!.actionable).toBe(true); - }); - - it('classifies security type correctly', () => { - const body = [ - 'In `@src/config.ts`:', - '- Line 88: This is a security concern: known vulnerabilities in imported package.', - ].join('\n'); - - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.type).toBe('security'); - expect(findings[0]!.severity).toBe('P2'); - expect(findings[0]!.actionable).toBe(true); - }); - - it('classifies supply chain security correctly', () => { - const body = [ - 'In `@src/tools/foo.ts`:', - '- Line 15: Supply chain attack vector — pin this action to a full commit hash.', - ].join('\n'); - - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.type).toBe('security'); - expect(findings[0]!.severity).toBe('P2'); - }); - - it('handles empty body', () => { - const findings = parseCodeRabbit(''); - expect(findings).toHaveLength(0); - }); - - it('handles AI agent prompt body (meta fallback)', () => { - const body = 'Prompt for AI: review all files for security issues.'; - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.type).toBe('meta'); - expect(findings[0]!.severity).toBe('info'); - expect(findings[0]!.actionable).toBe(false); - }); - - it('handles "finishing touches" meta body', () => { - const body = 'The review is in progress — finishing touches.'; - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.type).toBe('meta'); - expect(findings[0]!.severity).toBe('info'); - expect(findings[0]!.actionable).toBe(false); - }); - - it('handles line with dependency keyword', () => { - const body = [ - 'In `@package.json`:', - '- Line 5: peer dependency `lodash` is outdated. Consider updating.', - ].join('\n'); - - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.type).toBe('peer_dependency'); - expect(findings[0]!.severity).toBe('P1'); - }); - - it('suggestion extraction includes description when no update pattern found', () => { - const body = [ - 'In `@src/tools/foo.ts`:', - '- Line 35: Unused variable `x`. Consider removing it.', - ].join('\n'); - - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(1); - // When no "Update the..." pattern exists, suggestion falls back to description - expect(findings[0]!.suggestion).toBeTruthy(); - }); - - it('handles malformed content without crashing', () => { - const body = 'Some random text with no inline findings whatsoever.'; - const findings = parseCodeRabbit(body); - expect(findings).toHaveLength(0); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// Unit tests — parseCubicDev -// ──────────────────────────────────────────────────────────────── - -describe('parseCubicDev', () => { - it('parses single XML violation', () => { - const body = [ - '', - '', - 'P1: after_line: -1 is placed at the wrong position. The prepend block should come after imports.', - '', - '', - ].join('\n'); - - const findings = parseCubicDev(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.bot).toBe('cubic-dev-ai'); - expect(findings[0]!.file).toBe('src/tools/append-file.ts'); - expect(findings[0]!.line).toBe(48); - expect(findings[0]!.severity).toBe('P1'); - expect(findings[0]!.type).toBe('bug'); - expect(findings[0]!.actionable).toBe(true); - expect(findings[0]!.description).toBe( - 'after_line: -1 is placed at the wrong position. The prepend block should come after imports.' - ); - }); - - it('parses multiple violations in same file', () => { - const body = [ - '', - '', - 'P1: Function `bar` is missing type annotations.', - '', - '', - 'P2: Variable name `x` could be more descriptive.', - '', - '', - ].join('\n'); - - const findings = parseCubicDev(body); - expect(findings).toHaveLength(2); - expect(findings[0]!.line).toBe(10); - expect(findings[0]!.severity).toBe('P1'); - expect(findings[1]!.line).toBe(25); - expect(findings[1]!.severity).toBe('P2'); - }); - - it('parses violations across multiple files', () => { - const body = [ - '', - '', - 'P1: Missing error handling.', - '', - '', - '', - '', - 'P2: Unused import.', - '', - '', - ].join('\n'); - - const findings = parseCubicDev(body); - expect(findings).toHaveLength(2); - expect(findings[0]!.file).toBe('src/a.ts'); - expect(findings[1]!.file).toBe('src/b.ts'); - }); - - it('handles violation without explicit severity prefix', () => { - const body = [ - '', - '', - 'Some issue without P1/P2 prefix.', - '', - '', - ].join('\n'); - - const findings = parseCubicDev(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.severity).toBe('P2'); // default - expect(findings[0]!.description).toBe('Some issue without P1/P2 prefix.'); - }); - - it('handles empty body', () => { - const findings = parseCubicDev(''); - expect(findings).toHaveLength(0); - }); - - it('handles malformed XML gracefully', () => { - const body = 'Some random text with no XML structure.'; - const findings = parseCubicDev(body); - expect(findings).toHaveLength(0); - }); - - it('handles file with no violations', () => { - const body = '\n'; - const findings = parseCubicDev(body); - expect(findings).toHaveLength(0); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// Unit tests — parseDependabot -// ──────────────────────────────────────────────────────────────── - -describe('parseDependabot', () => { - it('parses bump from PR body', () => { - const body = 'Bumps @typescript-eslint/parser from 8.47.0 to 8.61.0'; - const findings = parseDependabot(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.bot).toBe('dependabot'); - expect(findings[0]!.type).toBe('dependency'); - expect(findings[0]!.severity).toBe('info'); - expect(findings[0]!.file).toBe('package.json'); - expect(findings[0]!.line).toBe(0); - expect(findings[0]!.description).toBe( - 'Bump @typescript-eslint/parser from 8.47.0 to 8.61.0' - ); - expect(findings[0]!.actionable).toBe(true); - }); - - it('parses bump with "Bump" (singular) variant', () => { - const body = 'Bump eslint-plugin-prettier from 5.5.5 to 5.5.6'; - const findings = parseDependabot(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.description).toBe( - 'Bump eslint-plugin-prettier from 5.5.5 to 5.5.6' - ); - }); - - it('parses bump with scoped package name', () => { - const body = 'Bumps @scope/package from 1.0.0 to 2.0.0'; - const findings = parseDependabot(body); - expect(findings).toHaveLength(1); - expect(findings[0]!.description).toBe('Bump @scope/package from 1.0.0 to 2.0.0'); - }); - - it('handles empty body', () => { - const findings = parseDependabot(''); - expect(findings).toHaveLength(0); - }); - - it('handles unrelated body', () => { - const body = 'This is just a regular comment about the PR.'; - const findings = parseDependabot(body); - expect(findings).toHaveLength(0); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// Unit tests — parseBotContent -// ──────────────────────────────────────────────────────────────── - -describe('parseBotContent', () => { - it('routes to coderabbitai parser correctly', () => { - const body = [ - 'In `@src/tools/foo.ts`:', - '- Line 35: Unused variable `x`.', - ].join('\n'); - - const findings = parseBotContent(body, 'coderabbitai[bot]'); - expect(findings).toHaveLength(1); - expect(findings[0]!.bot).toBe('coderabbitai'); - }); - - it('routes to cubic-dev-ai parser correctly', () => { - const body = [ - '', - '', - 'P1: Missing type annotation.', - '', - '', - ].join('\n'); - - const findings = parseBotContent(body, 'cubic-dev-ai[bot]'); - expect(findings).toHaveLength(1); - expect(findings[0]!.bot).toBe('cubic-dev-ai'); - }); - - it('routes to dependabot parser correctly', () => { - const body = 'Bumps some-package from 1.0.0 to 2.0.0'; - const findings = parseBotContent(body, 'dependabot[bot]'); - expect(findings).toHaveLength(1); - expect(findings[0]!.bot).toBe('dependabot'); - }); - - it('returns empty array for unknown bot', () => { - const findings = parseBotContent('Some comment body', 'some-other-bot'); - expect(findings).toHaveLength(0); - }); - - it('returns empty for empty body and known bot', () => { - const findings = parseBotContent('', 'coderabbitai[bot]'); - expect(findings).toHaveLength(0); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// Verify BotFinding type shape -// ──────────────────────────────────────────────────────────────── - -describe('BotFinding type structure', () => { - it('all findings have the correct shape', () => { - const finding: BotFinding = { - bot: 'coderabbitai', - type: 'nitpick', - severity: 'nitpick', - file: 'src/test.ts', - line: 42, - description: 'test finding', - suggestion: 'test suggestion', - actionable: false, - }; - - expect(finding).toHaveProperty('bot'); - expect(finding).toHaveProperty('type'); - expect(finding).toHaveProperty('severity'); - expect(finding).toHaveProperty('file'); - expect(finding).toHaveProperty('line'); - expect(finding).toHaveProperty('description'); - expect(finding).toHaveProperty('suggestion'); - expect(finding).toHaveProperty('actionable'); - }); -}); diff --git a/tests/gh-tools.test.ts b/tests/gh-tools.test.ts deleted file mode 100644 index 05bee1e..0000000 --- a/tests/gh-tools.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect } from 'bun:test'; -import { formatIssueList } from '../src/tools/gh-issue-list'; -import { formatPrStatus } from '../src/tools/gh-pr-status'; -import { formatBranchCleanup } from '../src/tools/gh-branch-cleanup'; -import { formatReleaseInfo } from '../src/tools/gh-release-info'; - -// ──────────────────────────────────────────────────────────────── -// Unit tests — formatIssueList -// ──────────────────────────────────────────────────────────────── - -describe('formatIssueList', () => { - it('formats a list of open issues', () => { - const issues = [ - { - number: 42, - title: 'Add login endpoint', - state: 'open', - labels: ['feature', 'backend'], - assignees: ['alice'], - url: 'https://github.com/org/repo/issues/42', - updatedAt: '2026-06-10T12:00:00Z', - }, - { - number: 41, - title: 'Fix null pointer in router', - state: 'open', - labels: ['bug'], - assignees: ['bob'], - url: 'https://github.com/org/repo/issues/41', - updatedAt: '2026-06-09T08:00:00Z', - }, - ]; - - const output = formatIssueList(issues, 'org/repo', 'open'); - expect(output).toContain('GH ISSUE LIST — org/repo — 2 open issues'); - expect(output).toContain('#42'); - expect(output).toContain('Add login endpoint'); - expect(output).toContain('[feature, backend]'); - expect(output).toContain('@alice'); - expect(output).toContain('#41'); - expect(output).toContain('Fix null pointer in router'); - expect(output).toContain('[bug]'); - expect(output).toContain('@bob'); - expect(output).toContain('2026-06-09'); - }); - - it('handles empty issue list', () => { - const output = formatIssueList([], 'org/repo', 'open'); - expect(output).toContain('GH ISSUE LIST — org/repo'); - expect(output).toContain('no open issues found'); - }); - - it('handles single issue (singular "issue")', () => { - const issues = [ - { - number: 1, - title: 'Initial setup', - state: 'closed', - labels: [], - assignees: [], - url: 'https://github.com/org/repo/issues/1', - updatedAt: '2026-01-01T00:00:00Z', - }, - ]; - - const output = formatIssueList(issues, 'org/repo', 'closed'); - expect(output).toContain('1 closed issue'); - // "1 closed issue" contains "issues" as substring; check singular form instead - expect(output).toMatch(/1 closed issue\b/); - expect(output).toContain('#1'); - expect(output).toContain('closed'); - }); - - it('shows state from parameter in header', () => { - const issues = [ - { - number: 99, - title: 'All states test', - state: 'open', - labels: [], - assignees: [], - url: 'https://github.com/org/repo/issues/99', - updatedAt: '2026-06-01T00:00:00Z', - }, - ]; - - const output = formatIssueList(issues, 'org/repo', 'all'); - expect(output).toContain('1 all issue'); - }); - - it('handles issues with no labels and no assignees', () => { - const issues = [ - { - number: 7, - title: 'Unassigned issue', - state: 'open', - labels: [], - assignees: [], - url: 'https://github.com/org/repo/issues/7', - updatedAt: '2026-06-10T00:00:00Z', - }, - ]; - - const output = formatIssueList(issues, 'org/repo', 'open'); - expect(output).toContain('#7'); - expect(output).toContain('Unassigned issue'); - // Should not have label brackets or assignee prefix when empty - expect(output).not.toContain('[]'); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// Unit tests — formatPrStatus -// ──────────────────────────────────────────────────────────────── - -describe('formatPrStatus', () => { - const basePr = { - number: 123, - title: 'feat: add user auth module', - state: 'OPEN', - mergeable: 'MERGEABLE' as const, - mergeStateStatus: 'CLEAN', - reviews: [], - statusCheckRollup: null, - url: 'https://github.com/org/repo/pull/123', - baseRefName: 'main', - headRefName: 'feature/auth', - }; - - it('formats a clean, mergeable PR with no reviews or CI', () => { - const output = formatPrStatus(basePr, 'org/repo'); - expect(output).toContain('PR STATUS — org/repo #123'); - expect(output).toContain('feat: add user auth module'); - expect(output).toContain('feature/auth → main'); - expect(output).toContain('✅ MERGEABLE'); - expect(output).toContain('ready to merge'); - expect(output).toContain('Reviews: none yet'); - expect(output).toContain('CI Checks: none configured'); - expect(output).toContain('Waiting for review approval'); - }); - - it('shows approved reviews correctly', () => { - const pr = { - ...basePr, - reviews: [ - { author: 'alice', state: 'APPROVED', submittedAt: '2026-06-10T12:00:00Z' }, - { author: 'bob', state: 'APPROVED', submittedAt: '2026-06-10T13:00:00Z' }, - ], - }; - - const output = formatPrStatus(pr, 'org/repo'); - expect(output).toContain('✅ 2 approvals (alice, bob)'); - expect(output).toContain('✅ Ready to merge!'); - }); - - it('shows changes requested review', () => { - const pr = { - ...basePr, - reviews: [ - { author: 'alice', state: 'CHANGES_REQUESTED', submittedAt: '2026-06-10T12:00:00Z' }, - ], - }; - - const output = formatPrStatus(pr, 'org/repo'); - expect(output).toContain('❌ 1 change requested (alice)'); - expect(output).toContain('Changes requested — address review feedback'); - }); - - it('shows CI check statuses', () => { - const pr = { - ...basePr, - reviews: [{ author: 'alice', state: 'APPROVED', submittedAt: '2026-06-10T12:00:00Z' }], - statusCheckRollup: [ - { name: 'lint', status: 'COMPLETED', conclusion: 'SUCCESS' }, - { name: 'test', status: 'COMPLETED', conclusion: 'FAILURE' }, - { name: 'build', status: 'IN_PROGRESS', conclusion: null }, - ], - }; - - const output = formatPrStatus(pr, 'org/repo'); - expect(output).toContain('✅ lint (COMPLETED — SUCCESS)'); - expect(output).toContain('❌ test (COMPLETED — FAILURE)'); - expect(output).toContain('🔄 build (IN_PROGRESS)'); - expect(output).toContain('CI checks failing — fix before merging'); - }); - - it('shows merge conflict status', () => { - const pr = { - ...basePr, - mergeable: 'CONFLICTING' as const, - mergeStateStatus: 'DIRTY', - reviews: [{ author: 'alice', state: 'APPROVED', submittedAt: '2026-06-10T12:00:00Z' }], - }; - - const output = formatPrStatus(pr, 'org/repo'); - expect(output).toContain('❌ CONFLICTING'); - expect(output).toContain('needs update'); - expect(output).toContain('Has merge conflicts — resolve before merging'); - }); - - it('handles unknown mergeable state', () => { - const pr = { - ...basePr, - mergeable: 'UNKNOWN' as const, - mergeStateStatus: 'UNKNOWN', - }; - - const output = formatPrStatus(pr, 'org/repo'); - expect(output).toContain('❓ UNKNOWN'); - expect(output).toContain('Mergeability unknown'); - }); - - it('shows commented reviews without decision', () => { - const pr = { - ...basePr, - reviews: [{ author: 'alice', state: 'COMMENTED', submittedAt: '2026-06-10T12:00:00Z' }], - }; - - const output = formatPrStatus(pr, 'org/repo'); - expect(output).toContain('💬 1 comment without decision (alice)'); - }); - - it('shows pending CI checks', () => { - const pr = { - ...basePr, - reviews: [{ author: 'alice', state: 'APPROVED', submittedAt: '2026-06-10T12:00:00Z' }], - statusCheckRollup: [{ name: 'deploy-preview', status: 'IN_PROGRESS', conclusion: null }], - }; - - const output = formatPrStatus(pr, 'org/repo'); - expect(output).toContain('CI checks still running'); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// Unit tests — formatBranchCleanup -// ──────────────────────────────────────────────────────────────── - -describe('formatBranchCleanup', () => { - const branches = [ - { - number: 42, - headRefName: 'feature/login', - baseRefName: 'main', - mergedAt: '2026-06-10T12:00:00Z', - }, - { - number: 41, - headRefName: 'fix/router-null', - baseRefName: 'main', - mergedAt: '2026-06-09T08:00:00Z', - }, - ]; - - it('formats dry run output with branches found', () => { - const output = formatBranchCleanup(branches, [], [], true, 'org/repo'); - expect(output).toContain('GH BRANCH CLEANUP — org/repo — DRY RUN'); - expect(output).toContain('Found 2 merged branches'); - expect(output).toContain('feature/login (PR #42 → main, merged 2026-06-10)'); - expect(output).toContain('fix/router-null (PR #41 → main, merged 2026-06-09)'); - expect(output).toContain('Run with dry_run=false to delete'); - }); - - it('formats dry run with no branches found', () => { - const output = formatBranchCleanup([], [], [], true, 'org/repo'); - expect(output).toContain('DRY RUN'); - expect(output).toContain('No stale merged branches found'); - }); - - it('formats successful deletion output', () => { - const deleted = ['feature/login', 'fix/router-null']; - const output = formatBranchCleanup(branches, deleted, [], false, 'org/repo'); - expect(output).toContain('GH BRANCH CLEANUP — org/repo'); - expect(output).not.toContain('DRY RUN'); - expect(output).toContain('Deleted 2 branches'); - expect(output).toContain('✓ feature/login'); - expect(output).toContain('✓ fix/router-null'); - }); - - it('formats mixed success/failure output', () => { - const deleted = ['feature/login']; - const failed = ['fix/router-null']; - const output = formatBranchCleanup(branches, deleted, failed, false, 'org/repo'); - expect(output).toContain('Deleted 1 branch'); - expect(output).toContain('✓ feature/login'); - expect(output).toContain('Failed to delete 1 branch'); - expect(output).toContain('✗ fix/router-null'); - }); - - it('handles all failures', () => { - const failed = ['feature/login', 'fix/router-null']; - const output = formatBranchCleanup(branches, [], failed, false, 'org/repo'); - expect(output).toContain('Failed to delete 2 branches'); - expect(output).toContain('✗ feature/login'); - expect(output).toContain('✗ fix/router-null'); - }); - - it('handles singular "branch" for single deletion', () => { - const deleted = ['feature/login']; - const output = formatBranchCleanup([branches[0]!], deleted, [], false, 'org/repo'); - expect(output).toContain('Deleted 1 branch'); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// Unit tests — formatReleaseInfo -// ──────────────────────────────────────────────────────────────── - -describe('formatReleaseInfo', () => { - const release = { - tagName: 'v1.2.3', - name: 'Release v1.2.3 — Performance Improvements', - body: "## What's new\n\n- Faster startup time\n- Reduced memory usage\n- New CLI flags", - publishedAt: '2026-06-10T14:00:00Z', - url: 'https://github.com/org/repo/releases/tag/v1.2.3', - assets: [ - { - name: 'app-linux-amd64.tar.gz', - size: 5242880, - downloadCount: 1523, - url: 'https://github.com/org/repo/releases/download/v1.2.3/app-linux-amd64.tar.gz', - }, - { - name: 'app-darwin-arm64.tar.gz', - size: 4890120, - downloadCount: 891, - url: 'https://github.com/org/repo/releases/download/v1.2.3/app-darwin-arm64.tar.gz', - }, - ], - }; - - it('formats full release info', () => { - const output = formatReleaseInfo(release, 'org/repo'); - expect(output).toContain('GH RELEASE — org/repo'); - expect(output).toContain('Tag: v1.2.3'); - expect(output).toContain('Title: Release v1.2.3 — Performance Improvements'); - expect(output).toContain('Published: 2026-06-10'); - expect(output).toContain('URL: https://github.com/org/repo/releases/tag/v1.2.3'); - expect(output).toContain('Release Notes:'); - expect(output).toContain('Faster startup time'); - expect(output).toContain('Assets (2)'); - expect(output).toContain('app-linux-amd64.tar.gz — 5.0 MB — 1523 downloads'); - expect(output).toContain('app-darwin-arm64.tar.gz — 4.7 MB — 891 downloads'); - }); - - it('handles release with no name (uses tagName)', () => { - const r = { ...release, name: null }; - const output = formatReleaseInfo(r, 'org/repo'); - expect(output).toContain('Title: v1.2.3'); - }); - - it('handles release with no assets', () => { - const r = { ...release, assets: [] }; - const output = formatReleaseInfo(r, 'org/repo'); - expect(output).toContain('Assets: none'); - }); - - it('handles release with no body', () => { - const r = { ...release, body: '' }; - const output = formatReleaseInfo(r, 'org/repo'); - expect(output).toContain('GH RELEASE — org/repo'); - // Should still render, just without notes section - expect(output).not.toContain('Release Notes:'); - }); - - it('formats file sizes correctly — KB', () => { - const r = { - ...release, - assets: [{ name: 'small.txt', size: 512, downloadCount: 10, url: 'https://example.com' }], - }; - const output = formatReleaseInfo(r, 'org/repo'); - expect(output).toContain('512 B'); - }); - - it('formats file sizes correctly — B', () => { - const r = { - ...release, - assets: [{ name: 'tiny.txt', size: 64, downloadCount: 1, url: 'https://example.com' }], - }; - const output = formatReleaseInfo(r, 'org/repo'); - expect(output).toContain('64 B'); - }); - - it('formats file sizes correctly — MB', () => { - const r = { - ...release, - assets: [ - { - name: 'large.zip', - size: 104857600, - downloadCount: 42, - url: 'https://example.com', - }, - ], - }; - const output = formatReleaseInfo(r, 'org/repo'); - expect(output).toContain('100.0 MB'); - }); -}); diff --git a/tests/git-diff.test.ts b/tests/git-diff.test.ts deleted file mode 100644 index 5d44c86..0000000 --- a/tests/git-diff.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect } from 'bun:test'; -import { parseMultiFileDiff, formatDiffOutput } from '../src/tools/git-diff'; - -const GIT_AVAILABLE = Bun.which('git') !== null; -const REPO_ROOT = process.cwd(); - -/** - * Run git directly via Bun.spawnSync, bypassing runGit from git-utils - * which is globally mocked by other test files. - */ -function gitDiff(args: string[]): string { - const result = Bun.spawnSync(['git', ...args], { - cwd: REPO_ROOT, - env: { ...process.env }, - }); - if (result.exitCode !== 0) { - throw new Error(`git exited with code ${result.exitCode}: ${result.stderr.toString().trim()}`); - } - return result.stdout.toString().trim(); -} - -// ──────────────────────────────────────────────────────────────── -// Unit tests — pure functions -// ──────────────────────────────────────────────────────────────── - -describe('parseMultiFileDiff', () => { - it('parses a simple multi-file diff', () => { - const raw = [ - 'diff --git a/src/file1.ts b/src/file1.ts', - 'index abc..def 100644', - '--- a/src/file1.ts', - '+++ b/src/file1.ts', - '@@ -1,3 +1,4 @@', - ' unchanged', - '-removed', - '+added', - ' still here', - ].join('\n'); - - const result = parseMultiFileDiff(raw); - expect(result).toHaveLength(1); - expect(result[0].path).toBe('src/file1.ts'); - expect(result[0].status).toBe('modified'); - expect(result[0].added).toBe(1); - expect(result[0].deleted).toBe(1); - }); - - it('parses new file creation', () => { - const raw = [ - 'diff --git a/newfile.ts b/newfile.ts', - 'new file mode 100644', - 'index 0000000..abc1234', - '--- /dev/null', - '+++ b/newfile.ts', - '@@ -0,0 +1,3 @@', - '+line1', - '+line2', - '+line3', - ].join('\n'); - - const result = parseMultiFileDiff(raw); - expect(result).toHaveLength(1); - expect(result[0].path).toBe('newfile.ts'); - expect(result[0].status).toBe('new'); - expect(result[0].added).toBe(3); - expect(result[0].deleted).toBe(0); - }); - - it('parses deleted file', () => { - const raw = [ - 'diff --git a/oldfile.ts b/oldfile.ts', - 'deleted file mode 100644', - 'index abc..def 100644', - '--- a/oldfile.ts', - '+++ /dev/null', - '@@ -1,2 +0,0 @@', - '-line1', - '-line2', - ].join('\n'); - - const result = parseMultiFileDiff(raw); - expect(result).toHaveLength(1); - expect(result[0].path).toBe('oldfile.ts'); - expect(result[0].status).toBe('deleted'); - expect(result[0].added).toBe(0); - expect(result[0].deleted).toBe(2); - }); - - it('parses multiple files', () => { - const raw = [ - 'diff --git a/a.ts b/a.ts', - 'index abc..def 100644', - '--- a/a.ts', - '+++ b/a.ts', - '@@ -1,1 +1,2 @@', - ' keep', - '+new', - '', - 'diff --git a/b.ts b/b.ts', - 'index ghi..jkl 100644', - '--- a/b.ts', - '+++ b/b.ts', - '@@ -1,1 +1,1 @@', - '-old', - '+new', - ].join('\n'); - - const result = parseMultiFileDiff(raw); - expect(result).toHaveLength(2); - expect(result[0].path).toBe('a.ts'); - expect(result[0].added).toBe(1); - expect(result[1].path).toBe('b.ts'); - expect(result[1].added).toBe(1); - expect(result[1].deleted).toBe(1); - }); - - it('handles empty input', () => { - const result = parseMultiFileDiff(''); - expect(result).toHaveLength(0); - }); - - it('handles diff with no hunks (binary file)', () => { - const raw = [ - 'diff --git a/image.png b/image.png', - 'index abc..def 100644', - 'Binary files a/image.png and b/image.png differ', - ].join('\n'); - - const result = parseMultiFileDiff(raw); - expect(result).toHaveLength(1); - expect(result[0].path).toBe('image.png'); - expect(result[0].added).toBe(0); - expect(result[0].deleted).toBe(0); - }); - - it('handles renamed file with 100% similarity', () => { - const raw = [ - 'diff --git a/old.ts b/new.ts', - 'similarity index 100%', - 'rename from old.ts', - 'rename to new.ts', - ].join('\n'); - - const result = parseMultiFileDiff(raw); - expect(result).toHaveLength(1); - expect(result[0].path).toBe('new.ts'); - expect(result[0].added).toBe(0); - expect(result[0].deleted).toBe(0); - }); - - it('deduplicates by path', () => { - const raw = [ - 'diff --git a/file.ts b/file.ts', - 'index abc..def 100644', - '--- a/file.ts', - '+++ b/file.ts', - '@@ -1,1 +1,1 @@', - '-a', - '+b', - ].join('\n'); - - const result = parseMultiFileDiff(raw); - expect(result).toHaveLength(1); - }); -}); - -describe('formatDiffOutput', () => { - it('formats single file diff', () => { - const files = [{ path: 'src/file.ts', status: 'modified' as const, added: 5, deleted: 3 }]; - const output = formatDiffOutput(files); - expect(output).toContain('GIT DIFF — 1 file changed'); - expect(output).toContain('src/file.ts'); - expect(output).toContain('+5'); - expect(output).toContain('-3'); - expect(output).toContain('(modified)'); - expect(output).toContain('Total: +5 -3'); - }); - - it('formats multi-file diff', () => { - const files = [ - { path: 'a.ts', status: 'modified' as const, added: 10, deleted: 2 }, - { path: 'b.ts', status: 'new' as const, added: 20, deleted: 0 }, - { path: 'c.ts', status: 'deleted' as const, added: 0, deleted: 5 }, - ]; - const output = formatDiffOutput(files); - expect(output).toContain('GIT DIFF — 3 files changed'); - expect(output).toContain('(new file)'); - expect(output).toContain('(deleted)'); - expect(output).toContain('Total: +30 -7'); - }); - - it('returns "No changes" for empty array', () => { - expect(formatDiffOutput([])).toBe('No changes to show.'); - }); -}); - -// ──────────────────────────────────────────────────────────────── -// Integration tests — actual git operations -// ──────────────────────────────────────────────────────────────── - -describe('git_diff integration', () => { - it( - 'parses HEAD diff (last commit)', - { - skip: !GIT_AVAILABLE, - }, - async () => { - const raw = gitDiff(['diff', 'HEAD~1', 'HEAD']); - expect(raw.length).toBeGreaterThan(0); - const parsed = parseMultiFileDiff(raw); - expect(parsed.length).toBeGreaterThan(0); - expect(parsed[0].added + parsed[0].deleted).toBeGreaterThan(0); - } - ); - - it( - 'returns empty for no changes (HEAD vs HEAD)', - { - skip: !GIT_AVAILABLE, - }, - async () => { - const raw = gitDiff(['diff', 'HEAD', 'HEAD']); - expect(raw.trim()).toBe(''); - } - ); - - it( - 'parses staged diff without crashing', - { - skip: !GIT_AVAILABLE, - }, - async () => { - const raw = gitDiff(['diff', '--staged']); - // May be empty if nothing is staged, but should not crash - const parsed = parseMultiFileDiff(raw); - expect(Array.isArray(parsed)).toBe(true); - } - ); - - it( - 'parses diff for a specific file', - { - skip: !GIT_AVAILABLE, - }, - async () => { - // Diff HEAD~1..HEAD for the entry file - const raw = gitDiff(['diff', 'HEAD~1', 'HEAD', '--', 'src/four-opencode-supertools.ts']); - const parsed = parseMultiFileDiff(raw); - expect(Array.isArray(parsed)).toBe(true); - if (parsed.length > 0) { - expect(parsed[0].path).toBe('src/four-opencode-supertools.ts'); - } - } - ); - - it( - 'correctly counts additions and deletions', - { - skip: !GIT_AVAILABLE, - }, - async () => { - const raw = gitDiff(['diff', 'HEAD~1', 'HEAD']); - const parsed = parseMultiFileDiff(raw); - // Verify that multi-file parsing gives consistent add/del totals - let totalAdds = 0; - let totalDels = 0; - for (const fd of parsed) { - totalAdds += fd.added; - totalDels += fd.deleted; - } - expect(totalAdds + totalDels).toBeGreaterThan(0); - } - ); - - it( - 'formatDiffOutput returns correct summary for git output', - { - skip: !GIT_AVAILABLE, - }, - async () => { - const raw = gitDiff(['diff', 'HEAD~1', 'HEAD']); - const parsed = parseMultiFileDiff(raw); - const output = formatDiffOutput(parsed); - expect(output).toMatch(/^GIT DIFF — \d+ file/); - expect(output).toContain('Total:'); - } - ); -}); diff --git a/tests/git-log-structured.test.ts b/tests/git-log-structured.test.ts deleted file mode 100644 index 3cbeda9..0000000 --- a/tests/git-log-structured.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect } from 'bun:test'; -import { parseStatOutput, formatLogOutput } from '../src/tools/git-log-structured'; - -describe('parseStatOutput', () => { - it('parses standard git show --stat output', () => { - const raw = [ - ' src/file1.ts | 10 ++++++----', - ' src/file2.ts | 5 +++--', - ' 2 files changed, 10 insertions(+), 6 deletions(-)', - ].join('\n'); - - const files = parseStatOutput(raw); - expect(files).toHaveLength(2); - expect(files[0].path).toBe('src/file1.ts'); - expect(files[0].added).toBe(6); - expect(files[0].deleted).toBe(4); - expect(files[1].path).toBe('src/file2.ts'); - expect(files[1].added).toBe(3); - expect(files[1].deleted).toBe(2); - }); - - it('handles new file with only additions', () => { - const raw = [' src/new.ts | 20 ++++++++++++++++++++', ' 1 file changed, 20 insertions(+)'].join( - '\n' - ); - - const files = parseStatOutput(raw); - expect(files).toHaveLength(1); - expect(files[0].path).toBe('src/new.ts'); - expect(files[0].added).toBe(20); - expect(files[0].deleted).toBe(0); - }); - - it('handles deleted file with only deletions', () => { - const raw = [' src/old.ts | 15 ---------------', ' 1 file changed, 15 deletions(-)'].join('\n'); - - const files = parseStatOutput(raw); - expect(files).toHaveLength(1); - expect(files[0].path).toBe('src/old.ts'); - expect(files[0].added).toBe(0); - expect(files[0].deleted).toBe(15); - }); - - it('skips binary files', () => { - const raw = [ - ' assets/logo.png | Bin 1234 -> 5678 bytes', - ' src/code.ts | 3 ++-', - ' 2 files changed, 2 insertions(+), 1 deletion(-)', - ].join('\n'); - - const files = parseStatOutput(raw); - expect(files).toHaveLength(1); - expect(files[0].path).toBe('src/code.ts'); - }); - - it('handles empty output', () => { - const files = parseStatOutput(''); - expect(files).toHaveLength(0); - }); - - it('handles files with only a number (no +/- signs)', () => { - const raw = [' src/simple.ts | 8', ' 1 file changed, 8 insertions(+)'].join('\n'); - - const files = parseStatOutput(raw); - expect(files).toHaveLength(1); - expect(files[0].path).toBe('src/simple.ts'); - expect(files[0].added).toBe(8); - expect(files[0].deleted).toBe(0); - }); -}); - -describe('formatLogOutput', () => { - it('formats summary output', () => { - const entries = [ - { - hash: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', - author: 'alice', - date: '2026-06-10T12:00:00+00:00', - subject: 'feat: add handler #42', - }, - { - hash: 'e4f5g6h7i8j9k0l1m2n3o4p5q6r7s8t9u0v1w2', - author: 'bob', - date: '2026-06-09T10:00:00+00:00', - subject: 'fix: null check in router #41', - }, - ]; - - const output = formatLogOutput(entries, 'summary'); - expect(output).toContain('GIT LOG — last 2 commits'); - expect(output).toContain('a1b2c3d'); - expect(output).toContain('alice'); - expect(output).toContain('2026-06-10'); - expect(output).toContain('feat: add handler #42'); - expect(output).toContain('e4f5g6h'); - expect(output).toContain('bob'); - }); - - it('formats detailed output with file stats', () => { - const entries = [ - { - hash: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', - author: 'alice', - date: '2026-06-10T12:00:00+00:00', - subject: 'feat: add handler #42', - files: [ - { path: 'src/core/handler.ts', added: 45, deleted: 3 }, - { path: 'tests/handler.test.ts', added: 32, deleted: 0 }, - ], - }, - ]; - - const output = formatLogOutput(entries, 'detailed'); - expect(output).toContain('GIT LOG — detailed'); - expect(output).toContain('feat: add handler #42'); - expect(output).toContain('src/core/handler.ts (+45, -3)'); - expect(output).toContain('tests/handler.test.ts (+32, -0)'); - }); - - it('handles empty entries', () => { - const output = formatLogOutput([], 'summary'); - expect(output).toContain('last 0 commits'); - }); - - it('detailed without files still renders', () => { - const entries = [ - { - hash: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', - author: 'alice', - date: '2026-06-10T12:00:00+00:00', - subject: 'chore: update dependencies', - }, - ]; - - const output = formatLogOutput(entries, 'detailed'); - expect(output).toContain('alice'); - expect(output).toContain('chore: update dependencies'); - expect(output).not.toContain('Files:'); - }); - - it('uses singular "commit" for single entry', () => { - const entries = [ - { - hash: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', - author: 'alice', - date: '2026-06-10T12:00:00+00:00', - subject: 'feat: add handler #42', - }, - ]; - - const output = formatLogOutput(entries, 'summary'); - expect(output).toContain('last 1 commit'); - }); -}); diff --git a/tests/implicit-coupling.test.ts b/tests/implicit-coupling.test.ts deleted file mode 100644 index d5455cf..0000000 --- a/tests/implicit-coupling.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect } from 'bun:test'; -import { computeCoupling } from '../src/tools/implicit-coupling'; -import type { Commit } from '../src/lib/git-utils'; - -function makeCommit( - hash: string, - author: string, - files: { path: string; added: number; deleted: number }[] -): Commit { - return { hash, author, date: '2024-01-15T00:00:00Z', files }; -} - -describe('computeCoupling', () => { - it('returns empty for no multi-file commits', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', [{ path: 'src/a.ts', added: 1, deleted: 0 }]), - ]; - - const results = computeCoupling(commits, 0.5); - expect(results).toHaveLength(0); - }); - - it('finds perfectly coupled file pairs (1.0)', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/b.ts', added: 1, deleted: 0 }, - ]), - makeCommit('b'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/b.ts', added: 1, deleted: 0 }, - ]), - ]; - - const results = computeCoupling(commits, 0.5); - expect(results.length).toBeGreaterThanOrEqual(1); - const pair = results.find((r) => r.files.includes('src/a.ts') && r.files.includes('src/b.ts')); - expect(pair).toBeDefined(); - expect(pair!.coCommits).toBe(2); - expect(pair!.couplingStrength).toBe(1.0); - }); - - it('computes partial coupling strength correctly', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/b.ts', added: 1, deleted: 0 }, - ]), - makeCommit('b'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/c.ts', added: 1, deleted: 0 }, - ]), - ]; - - // a-b: co-commit once out of max(a=2, b=1) = 2 → 0.5 - // a-c: co-commit once out of max(a=2, c=1) = 2 → 0.5 - // b-c: co-commit 0 out of max(b=1, c=1) = 1 → 0.0 - - // With threshold 0.4, a-b and a-c should appear - const results = computeCoupling(commits, 0.4); - expect(results.length).toBe(2); - - // Verify a-b pair - const abPair = results.find( - (r) => r.files.includes('src/a.ts') && r.files.includes('src/b.ts') - ); - expect(abPair).toBeDefined(); - expect(abPair!.couplingStrength).toBe(0.5); - }); - - it('respects threshold parameter', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/b.ts', added: 1, deleted: 0 }, - ]), - makeCommit('b'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/c.ts', added: 1, deleted: 0 }, - ]), - ]; - - // Threshold 0.9 — nothing passes (a-b = 0.5, a-c = 0.5) - const highThreshold = computeCoupling(commits, 0.9); - expect(highThreshold).toHaveLength(0); - - // Threshold 0.4 — a-b passes - const lowThreshold = computeCoupling(commits, 0.4); - expect(lowThreshold.length).toBeGreaterThan(0); - }); - - it('handles multiple pairs in one commit', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/b.ts', added: 1, deleted: 0 }, - { path: 'src/c.ts', added: 1, deleted: 0 }, - ]), - ]; - - const results = computeCoupling(commits, 0.5); - // 3 files = 3 pairs: ab, ac, bc — all with strength 1.0 - expect(results).toHaveLength(3); - expect(results[0].couplingStrength).toBe(1.0); - }); - - it('sorts by coupling strength descending', () => { - const commits: Commit[] = [ - // a-b always together (perfect coupling) - makeCommit('a'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/b.ts', added: 1, deleted: 0 }, - ]), - makeCommit('b'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/b.ts', added: 1, deleted: 0 }, - { path: 'src/c.ts', added: 1, deleted: 0 }, - ]), - ]; - - const results = computeCoupling(commits, 0.3); - expect(results.length).toBeGreaterThanOrEqual(1); - // First result should have highest strength - expect(results[0].couplingStrength).toBeGreaterThanOrEqual( - results[results.length - 1].couplingStrength - ); - }); - - it('returns empty for empty commits', () => { - const results = computeCoupling([], 0.5); - expect(results).toHaveLength(0); - }); - - it('handles threshold of 0 (returns all pairs)', () => { - const commits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', [ - { path: 'src/a.ts', added: 1, deleted: 0 }, - { path: 'src/b.ts', added: 1, deleted: 0 }, - ]), - ]; - - const results = computeCoupling(commits, 0); - expect(results).toHaveLength(1); - }); -}); diff --git a/tests/ownership.test.ts b/tests/ownership.test.ts deleted file mode 100644 index 5e7a516..0000000 --- a/tests/ownership.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test'; -import { tmpdir } from 'node:os'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -// Mock runGit before importing the module that uses it -const runGitMock = mock(); - -mock.module('../src/lib/git-utils', () => ({ - ...require('../src/lib/git-utils'), - runGit: runGitMock, - parseGitBlame: async (filePath: string, cwd?: string) => { - const output = await runGitMock( - ['blame', '--line-porcelain', '--', filePath], - cwd ?? process.cwd() - ); - const { parseBlameOutput } = require('../src/lib/git-utils'); - return parseBlameOutput(output); - }, - parseGitBlameForDir: async (dirPath: string, cwd?: string) => { - const workDir = cwd ?? process.cwd(); - const result = new Map(); - let fileList: string; - try { - fileList = await runGitMock(['ls-files', '--', dirPath], workDir); - } catch { - return result; - } - const files = fileList.split('\n').filter((f: string) => f.trim() !== ''); - const { parseBlameOutput } = require('../src/lib/git-utils'); - for (const file of files) { - const output = await runGitMock(['blame', '--line-porcelain', '--', file], workDir); - const blame = parseBlameOutput(output); - if (blame.length > 0) result.set(file, blame); - } - return result; - }, -})); - -import { computeOwnership } from '../src/tools/ownership'; - -describe('computeOwnership', () => { - let testDir: string; - - beforeEach(() => { - testDir = join(tmpdir(), `ownership-test-${Date.now()}`); - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - try { - rmSync(testDir, { recursive: true }); - } catch { - /* ignore */ - } - }); - - it('returns ownership data for a file (mocked blame)', async () => { - // Create a dummy file - writeFileSync(join(testDir, 'test.ts'), 'line1\nline2\nline3\n'); - - const blameOutput = [ - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 3', - 'author alice', - '\tline1', - '\tline2', - '\tline3', - ].join('\n'); - - runGitMock.mockImplementation(async (args: string[]) => { - return blameOutput; - }); - - const result = await computeOwnership('test.ts', testDir); - expect(result).toContain('OWNERSHIP — test.ts'); - expect(result).toContain('(3 lines)'); - expect(result).toContain('alice'); - expect(result).toContain('100%)'); - expect(result).toContain('KNOWLEDGE SILO'); - }); - - it('shows no knowledge silo when ownership <= 80%', async () => { - writeFileSync(join(testDir, 'shared.ts'), 'line1\nline2\nline3\n'); - - const blameOutput = [ - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 2', - 'author alice', - '\tline1', - '\tline2', - 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 3 3 1', - 'author bob', - '\tline3', - ].join('\n'); - - runGitMock.mockImplementation(async () => blameOutput); - - const result = await computeOwnership('shared.ts', testDir); - expect(result).toContain('alice'); - expect(result).toContain('bob'); - expect(result).toContain('no knowledge silo'); - }); - - it('returns "Path not found" for non-existent path', async () => { - const result = await computeOwnership('nonexistent.ts', testDir); - expect(result).toContain('Path not found'); - }); - - it('returns "File has no lines" for empty file', async () => { - writeFileSync(join(testDir, 'empty.ts'), ''); - - runGitMock.mockImplementation(async () => ''); - - const result = await computeOwnership('empty.ts', testDir); - expect(result).toContain('File has no lines'); - }); - - it('returns directory ownership summary', async () => { - mkdirSync(join(testDir, 'src'), { recursive: true }); - writeFileSync(join(testDir, 'src', 'a.ts'), 'line1\n'); - - // Mock ls-files to return the file - const blameOutput = [ - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 1', - 'author dougwilson', - '\tline1', - ].join('\n'); - - let callCount = 0; - runGitMock.mockImplementation(async (args: string[]) => { - callCount++; - if (args[0] === 'ls-files') { - return 'src/a.ts'; - } - return blameOutput; - }); - - const result = await computeOwnership('src', testDir); - expect(result).toContain('OWNERSHIP — src/'); - expect(result).toContain('dougwilson'); - expect(result).toContain('KNOWLEDGE SILO'); - }); - - it('returns "No source files in directory" for empty dir', async () => { - mkdirSync(join(testDir, 'empty-dir'), { recursive: true }); - - runGitMock.mockImplementation(async (args: string[]) => { - return ''; // no files - }); - - const result = await computeOwnership('empty-dir', testDir); - // The mock returns empty ls-files which yields empty map → "No source files in directory" - expect(result).toContain('No source files in directory'); - }); - - it('handles multiple authors with correct percentages', async () => { - writeFileSync(join(testDir, 'multi.ts'), 'line\n'); - - const blameOutput = [ - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 4', - 'author dougwilson', - '\tline1', - '\tline2', - '\tline3', - 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 4 4 2', - 'author alice', - '\tline4', - '\tline5', - ].join('\n'); - - runGitMock.mockImplementation(async () => blameOutput); - - const result = await computeOwnership('multi.ts', testDir); - expect(result).toContain('dougwilson'); - expect(result).toContain('alice'); - // 3/5 = 60%, 2/5 = 40% - expect(result).toContain('60%)'); - expect(result).toContain('40%)'); - }); -}); diff --git a/tests/pr-risk.test.ts b/tests/pr-risk.test.ts deleted file mode 100644 index a2ed565..0000000 --- a/tests/pr-risk.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect } from 'bun:test'; -import { formatPrRiskOutput } from '../src/tools/pr-risk'; - -describe('formatPrRiskOutput', () => { - it('formats LOW risk output', () => { - const fileRisks = [ - { file: 'README.md', curseScore: 10, isTopDangerous: false, isNew: false, isTest: false }, - ]; - - const output = formatPrRiskOutput(fileRisks, [], 'LOW', false, '', false); - expect(output).toContain('PR RISK — 1 file changed'); - expect(output).toContain('Risk level: LOW'); - expect(output).toContain('README.md'); - }); - - it('formats MEDIUM risk with coupling', () => { - const fileRisks = [ - { - file: 'src/handler.ts', - curseScore: 1842, - isTopDangerous: true, - isNew: false, - isTest: false, - }, - { - file: 'src/middleware.ts', - curseScore: 1201, - isTopDangerous: false, - isNew: false, - isTest: false, - }, - ]; - - const coupling = [{ fileA: 'src/handler.ts', fileB: 'src/middleware.ts', coCommitRate: 0.88 }]; - - const output = formatPrRiskOutput(fileRisks, coupling, 'MEDIUM', false, '', false); - expect(output).toContain('MEDIUM'); - expect(output).toContain('top 3 most dangerous file'); - expect(output).toContain('co-commit rate: 0.88'); - expect(output).toContain('These files change together'); - }); - - it('marks HIGH risk when curse sum > 2000 with coupling', () => { - const fileRisks = [ - { - file: 'src/a.ts', - curseScore: 1500, - isTopDangerous: true, - isNew: false, - isTest: false, - }, - { - file: 'src/b.ts', - curseScore: 800, - isTopDangerous: false, - isNew: false, - isTest: false, - }, - ]; - - const coupling = [{ fileA: 'src/a.ts', fileB: 'src/b.ts', coCommitRate: 0.75 }]; - const output = formatPrRiskOutput(fileRisks, coupling, 'HIGH', false, '', false); - expect(output).toContain('HIGH'); - }); - - it('marks CRITICAL risk when curse sum > 5000 with high coupling', () => { - const fileRisks = [ - { - file: 'src/a.ts', - curseScore: 3000, - isTopDangerous: true, - isNew: false, - isTest: false, - }, - { - file: 'src/b.ts', - curseScore: 2500, - isTopDangerous: true, - isNew: false, - isTest: false, - }, - ]; - - const coupling = [{ fileA: 'src/a.ts', fileB: 'src/b.ts', coCommitRate: 0.9 }]; - const output = formatPrRiskOutput(fileRisks, coupling, 'CRITICAL', false, '', false); - expect(output).toContain('CRITICAL'); - }); - - it('detects low bus factor warning', () => { - const fileRisks = [ - { - file: 'src/core.ts', - curseScore: 500, - isTopDangerous: false, - isNew: false, - isTest: false, - }, - ]; - - const output = formatPrRiskOutput(fileRisks, [], 'MEDIUM', true, 'Alice', false); - expect(output).toContain('Low bus factor'); - expect(output).toContain('Alice'); - }); - - it('notes test-only changes', () => { - const fileRisks = [ - { - file: 'tests/a.test.ts', - curseScore: 45, - isTopDangerous: false, - isNew: false, - isTest: true, - }, - { - file: 'tests/b.test.ts', - curseScore: 25, - isTopDangerous: false, - isNew: false, - isTest: true, - }, - ]; - - const output = formatPrRiskOutput(fileRisks, [], 'LOW', false, '', true); - expect(output).toContain('test-only changes'); - }); - - it('marks new files with no history', () => { - const fileRisks = [ - { file: 'src/new_file.ts', curseScore: 0, isTopDangerous: false, isNew: true, isTest: false }, - ]; - - const output = formatPrRiskOutput(fileRisks, [], 'LOW', false, '', false); - expect(output).toContain('new file, no history to score'); - }); - - it('formats plural correctly for single file', () => { - const fileRisks = [ - { file: 'src/solo.ts', curseScore: 100, isTopDangerous: false, isNew: false, isTest: false }, - ]; - - const output = formatPrRiskOutput(fileRisks, [], 'LOW', false, '', false); - expect(output).toContain('1 file changed'); - }); -}); diff --git a/tests/trend.test.ts b/tests/trend.test.ts deleted file mode 100644 index 70f0d4c..0000000 --- a/tests/trend.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2025-2026 Four Bytes - -import { describe, it, expect } from 'bun:test'; -import { computeTrend, formatTrendOutput } from '../src/tools/trend'; -import type { Commit } from '../src/lib/git-utils'; - -// Helper: create a commit with given files -function makeCommit( - hash: string, - author: string, - date: string, - files: { path: string; added: number; deleted: number }[] -): Commit { - return { hash, author, date, files }; -} - -describe('computeTrend', () => { - it('detects files with increasing curse score (positive trend)', () => { - const recentCommits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', '2026-06-10T00:00:00Z', [ - { path: 'src/hot.ts', added: 50, deleted: 10 }, - { path: 'src/warm.ts', added: 20, deleted: 5 }, - ]), - makeCommit('b'.repeat(40), 'Bob', '2026-06-09T00:00:00Z', [ - { path: 'src/hot.ts', added: 30, deleted: 5 }, - ]), - makeCommit('c'.repeat(40), 'Alice', '2026-06-08T00:00:00Z', [ - { path: 'src/hot.ts', added: 10, deleted: 2 }, - ]), - ]; - - const olderCommits: Commit[] = [ - makeCommit('d'.repeat(40), 'Alice', '2026-03-01T00:00:00Z', [ - { path: 'src/hot.ts', added: 5, deleted: 1 }, - ]), - makeCommit('e'.repeat(40), 'Alice', '2026-02-01T00:00:00Z', [ - { path: 'src/warm.ts', added: 3, deleted: 1 }, - ]), - ]; - - const result = computeTrend(recentCommits, olderCommits, 90, 10); - - expect(result.insufficientHistory).toBe(false); - expect(result.worsening.length).toBeGreaterThan(0); - // Files with recent activity should show positive trend - const hotFile = result.worsening.find((r) => r.file === 'src/hot.ts'); - expect(hotFile).toBeDefined(); - expect(hotFile!.delta).toBeGreaterThan(0); - expect(hotFile!.recentScore).toBeGreaterThan(hotFile!.olderScore); - }); - - it('detects improving files (negative trend)', () => { - const recentCommits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', '2026-06-01T00:00:00Z', [ - { path: 'src/calming.ts', added: 2, deleted: 1 }, - ]), - ]; - - const olderCommits: Commit[] = [ - makeCommit('b'.repeat(40), 'Bob', '2026-02-01T00:00:00Z', [ - { path: 'src/calming.ts', added: 30, deleted: 15 }, - ]), - makeCommit('c'.repeat(40), 'Bob', '2026-01-15T00:00:00Z', [ - { path: 'src/calming.ts', added: 20, deleted: 10 }, - ]), - makeCommit('d'.repeat(40), 'Alice', '2026-01-01T00:00:00Z', [ - { path: 'src/calming.ts', added: 15, deleted: 5 }, - ]), - ]; - - const result = computeTrend(recentCommits, olderCommits, 90, 10); - - expect(result.improving.length).toBeGreaterThan(0); - const calmFile = result.improving.find((r) => r.file === 'src/calming.ts'); - expect(calmFile).toBeDefined(); - expect(calmFile!.delta).toBeLessThan(0); - }); - - it('respects top parameter', () => { - const recentCommits: Commit[] = []; - const olderCommits: Commit[] = []; - - // Create commits with many distinct files - for (let i = 0; i < 20; i++) { - recentCommits.push( - makeCommit( - `${String(i).repeat(40 - String(i).length)}${'a'.repeat(Math.max(0, 40 - String(i).length))}`, - 'Alice', - '2026-06-01T00:00:00Z', - [{ path: `src/file${i}.ts`, added: i + 10, deleted: i }] - ) - ); - olderCommits.push( - makeCommit( - `${String(i + 100).repeat(40 - String(i + 100).length)}${'b'.repeat(Math.max(0, 40 - String(i + 100).length))}`, - 'Alice', - '2026-02-01T00:00:00Z', - [{ path: `src/file${i}.ts`, added: i, deleted: 0 }] - ) - ); - } - - const result = computeTrend(recentCommits, olderCommits, 90, 5); - expect(result.worsening.length).toBeLessThanOrEqual(5); - }); - - it('handles insufficient history (no older commits)', () => { - const recentCommits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', '2026-06-01T00:00:00Z', [ - { path: 'src/new.ts', added: 10, deleted: 0 }, - ]), - ]; - - const result = computeTrend(recentCommits, [], 90, 10); - expect(result.insufficientHistory).toBe(true); - expect(result.worsening).toHaveLength(0); - }); - - it('marks new files (no older score)', () => { - const recentCommits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', '2026-06-10T00:00:00Z', [ - { path: 'src/new.ts', added: 30, deleted: 5 }, - ]), - ]; - - const olderCommits: Commit[] = [ - makeCommit('b'.repeat(40), 'Alice', '2026-03-01T00:00:00Z', [ - { path: 'src/old.ts', added: 10, deleted: 0 }, - ]), - ]; - - const result = computeTrend(recentCommits, olderCommits, 90, 10); - const newFile = result.worsening.find((r) => r.file === 'src/new.ts'); - expect(newFile).toBeDefined(); - expect(newFile!.note).toBe('new file, no older score'); - expect(newFile!.olderScore).toBe(0); - }); - - it('marks deleted files', () => { - const recentCommits: Commit[] = [ - makeCommit('a'.repeat(40), 'Alice', '2026-06-01T00:00:00Z', [ - { path: 'src/current.ts', added: 5, deleted: 0 }, - ]), - ]; - - const olderCommits: Commit[] = [ - makeCommit('b'.repeat(40), 'Alice', '2026-03-01T00:00:00Z', [ - { path: 'src/deleted.ts', added: 20, deleted: 5 }, - ]), - ]; - - const result = computeTrend(recentCommits, olderCommits, 90, 10); - const deletedFile = result.improving.find((r) => r.file === 'src/deleted.ts'); - expect(deletedFile).toBeDefined(); - expect(deletedFile!.note).toBe('deleted'); - expect(deletedFile!.recentScore).toBe(0); - expect(deletedFile!.delta).toBeLessThan(0); - }); - - it('returns empty when no commits at all', () => { - const result = computeTrend([], [], 90, 10); - expect(result.worsening).toHaveLength(0); - expect(result.improving).toHaveLength(0); - expect(result.insufficientHistory).toBe(false); - }); -}); - -describe('formatTrendOutput', () => { - it('formats worsening and improving files', () => { - const result = { - insufficientHistory: false, - worsening: [ - { file: 'src/hot.ts', recentScore: 100, olderScore: 50, delta: 50 }, - { file: 'src/warm.ts', recentScore: 80, olderScore: 60, delta: 20 }, - ], - improving: [{ file: 'src/cool.ts', recentScore: 20, olderScore: 80, delta: -60 }], - }; - - const output = formatTrendOutput(result, 90, 10); - expect(output).toContain('TREND'); - expect(output).toContain('src/hot.ts'); - expect(output).toContain('Δ +50'); - expect(output).toContain('src/cool.ts'); - expect(output).toContain('Δ -60'); - expect(output).toContain('Improving:'); - }); - - it('shows insufficient history message', () => { - const output = formatTrendOutput( - { insufficientHistory: true, worsening: [], improving: [] }, - 90, - 10 - ); - expect(output).toContain('insufficient history'); - }); - - it('shows no changes message for empty results', () => { - const output = formatTrendOutput( - { insufficientHistory: false, worsening: [], improving: [] }, - 90, - 10 - ); - expect(output).toContain('no significant changes'); - }); - - it('includes note for new files', () => { - const result = { - insufficientHistory: false, - worsening: [ - { - file: 'src/new.ts', - recentScore: 50, - olderScore: 0, - delta: 50, - note: 'new file, no older score', - }, - ], - improving: [], - }; - - const output = formatTrendOutput(result, 90, 10); - expect(output).toContain('[new file, no older score]'); - }); -}); From 263c46fbf2808551c577f6eea26fa4eb93eb2d70 Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Fri, 19 Jun 2026 00:09:45 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20add=206=20new=20supertools=20?= =?UTF-8?q?=E2=80=94=20smart=5Fedit,=20smart=5Fpatch,=20batch=5Fpatch,=20f?= =?UTF-8?q?ile=5Ftree,=20research,=20solution=5Fconfidence=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/four-opencode-supertools.ts | 12 +++ src/tools/batch-patch.ts | 83 ++++++++++++++++++++ src/tools/file-tree.ts | 105 +++++++++++++++++++++++++ src/tools/research.ts | 67 ++++++++++++++++ src/tools/smart-edit.ts | 106 +++++++++++++++++++++++++ src/tools/smart-patch.ts | 123 ++++++++++++++++++++++++++++++ src/tools/solution-confidence.ts | 97 +++++++++++++++++++++++ tests/batch-patch.test.ts | 80 +++++++++++++++++++ tests/file-tree.test.ts | 60 +++++++++++++++ tests/research.test.ts | 52 +++++++++++++ tests/smart-edit.test.ts | 118 ++++++++++++++++++++++++++++ tests/smart-patch.test.ts | 94 +++++++++++++++++++++++ tests/solution-confidence.test.ts | 53 +++++++++++++ 13 files changed, 1050 insertions(+) create mode 100644 src/tools/batch-patch.ts create mode 100644 src/tools/file-tree.ts create mode 100644 src/tools/research.ts create mode 100644 src/tools/smart-edit.ts create mode 100644 src/tools/smart-patch.ts create mode 100644 src/tools/solution-confidence.ts create mode 100644 tests/batch-patch.test.ts create mode 100644 tests/file-tree.test.ts create mode 100644 tests/research.test.ts create mode 100644 tests/smart-edit.test.ts create mode 100644 tests/smart-patch.test.ts create mode 100644 tests/solution-confidence.test.ts diff --git a/src/four-opencode-supertools.ts b/src/four-opencode-supertools.ts index f5c5d6a..459efc5 100644 --- a/src/four-opencode-supertools.ts +++ b/src/four-opencode-supertools.ts @@ -6,6 +6,12 @@ import { batchEditTool } from './tools/batch-edit'; import { lintFileTool } from './tools/lint-file'; import { runTestsTool } from './tools/run-tests'; import { appendFileTool } from './tools/append-file'; +import { smartEditTool } from './tools/smart-edit'; +import { smartPatchTool } from './tools/smart-patch'; +import { batchPatchTool } from './tools/batch-patch'; +import { fileTreeTool } from './tools/file-tree'; +import { researchTool } from './tools/research'; +import { solutionConfidenceTool } from './tools/solution-confidence'; const FourOpencodeSupertools: Plugin = async (_ctx) => { return { @@ -14,6 +20,12 @@ const FourOpencodeSupertools: Plugin = async (_ctx) => { lint_file: lintFileTool, run_tests: runTestsTool, append_file: appendFileTool, + smart_edit: smartEditTool, + smart_patch: smartPatchTool, + batch_patch: batchPatchTool, + file_tree: fileTreeTool, + research: researchTool, + solution_confidence: solutionConfidenceTool, }, }; }; diff --git a/src/tools/batch-patch.ts b/src/tools/batch-patch.ts new file mode 100644 index 0000000..ae37684 --- /dev/null +++ b/src/tools/batch-patch.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; +import { logDebugEvent } from '../lib/debug-logger'; +import { smartPatchTool } from './smart-patch'; + +export const batchPatchTool = tool({ + description: `Apply patches to multiple files in one call. Optional atomic mode: snapshot all files, apply all, rollback on any failure.`, + + args: { + patches: tool.schema.string().describe('JSON array of { file_path: string, patch: string } objects'), + atomic: tool.schema.boolean().optional().describe('If true, rollback ALL files on any failure (default: false)'), + }, + + async execute(args, _ctx) { + const patches: Array<{ file_path: string; patch: string }> = JSON.parse(args.patches); + + if (!Array.isArray(patches)) { + throw new Error('patches must be a JSON array'); + } + + logDebugEvent('batch_patch.start', { count: patches.length, atomic: args.atomic ?? false }); + + const applied: string[] = []; + const failed: Array<{ file: string; error: string }> = []; + const snapshots = new Map(); + + // Take snapshots if atomic + if (args.atomic) { + for (const p of patches) { + try { + if (existsSync(p.file_path)) { + snapshots.set(p.file_path, readFileSync(p.file_path, 'utf-8')); + } else { + snapshots.set(p.file_path, ''); // File doesn't exist yet + } + } catch { + snapshots.set(p.file_path, ''); + } + } + } + + for (const p of patches) { + try { + await smartPatchTool.execute({ file_path: p.file_path, patch: p.patch }, {} as any); + applied.push(p.file_path); + } catch (e: unknown) { + const errMsg = e instanceof Error ? e.message : String(e); + failed.push({ file: p.file_path, error: errMsg }); + + if (args.atomic) { + logDebugEvent('batch_patch.rollback', { file: p.file_path, error: errMsg }); + // Rollback all snapshots + for (const [path, content] of snapshots) { + if (content === '') { + try { + unlinkSync(path); + } catch { + /* ignore */ + } + } else { + try { + writeFileSync(path, content, 'utf-8'); + } catch { + /* ignore */ + } + } + } + return { + applied: [], + failed, + rolled_back: applied.length > 0 ? applied : [], + }; + } + } + } + + logDebugEvent('batch_patch.complete', { applied: applied.length, failed: failed.length }); + return { applied, failed }; + }, +}); diff --git a/src/tools/file-tree.ts b/src/tools/file-tree.ts new file mode 100644 index 0000000..f9c202b --- /dev/null +++ b/src/tools/file-tree.ts @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { readdirSync, statSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { logDebugEvent } from '../lib/debug-logger'; + +interface FileNode { + name: string; + type: 'file' | 'dir'; + size?: number; + children?: FileNode[]; +} + +const SKIP_DIRS = new Set(['.git', 'node_modules', 'vendor']); + +function walkDir(dir: string, depth: number, maxDepth: number, filter?: string, includeHidden = false): FileNode[] { + if (depth > maxDepth) return []; + + const results: FileNode[] = []; + + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; + } + + for (const entry of entries.sort()) { + if (!includeHidden && entry.startsWith('.') && entry !== '.gitignore') continue; + + const fullPath = join(dir, entry); + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; + } + + if (stat.isDirectory()) { + if (SKIP_DIRS.has(entry) && !includeHidden) continue; + const children = walkDir(fullPath, depth + 1, maxDepth, filter, includeHidden); + results.push({ + name: entry + '/', + type: 'dir', + children: children.length > 0 ? children : undefined, + }); + } else { + if (filter) { + const regex = new RegExp('^' + filter.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); + if (!regex.test(entry)) continue; + } + results.push({ name: entry, type: 'file', size: stat.size }); + } + } + + return results; +} + +export const fileTreeTool = tool({ + description: `List directory contents as a structured tree with file sizes. Skips .git, node_modules, vendor by default. Respects .gitignore. Use instead of bash ls/find for parsed output.`, + + args: { + path: tool.schema.string().describe('Directory path to list'), + depth: tool.schema + .number() + .optional() + .describe('Maximum depth (default: 3)'), + filter: tool.schema + .string() + .optional() + .describe('Glob pattern to filter files (e.g., "*.ts")'), + include_hidden: tool.schema + .boolean() + .optional() + .describe('Include hidden files and directories (default: false)'), + }, + + async execute(args, _ctx) { + const targetPath = args.path; + const depth = args.depth ?? 3; + + logDebugEvent('file_tree.start', { path: targetPath, depth }); + + if (!existsSync(targetPath)) { + throw new Error(`Path not found: ${targetPath}`); + } + + if (!statSync(targetPath).isDirectory()) { + const stat = statSync(targetPath); + return [ + { + name: targetPath.split('/').pop() || targetPath, + type: 'file' as const, + size: stat.size, + }, + ]; + } + + const results = walkDir(targetPath, 0, depth, args.filter, args.include_hidden); + logDebugEvent('file_tree.complete', { path: targetPath, entries: results.length }); + return results; + }, +}); diff --git a/src/tools/research.ts b/src/tools/research.ts new file mode 100644 index 0000000..0a0ded6 --- /dev/null +++ b/src/tools/research.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { logDebugEvent } from '../lib/debug-logger'; + +export const researchTool = tool({ + description: `Search both the brain knowledge base and the web in parallel. Combines brain_search + websearch into a single call. Saves 2 round trips.`, + + args: { + queries: tool.schema.string().describe('JSON array of search queries (strings)'), + scope: tool.schema + .string() + .optional() + .describe("Search scope: 'brain' (local only), 'web' (internet only), or 'both' (default)"), + }, + + async execute(args, ctx) { + const queries: string[] = JSON.parse(args.queries); + if (!Array.isArray(queries)) { + throw new Error('queries must be a JSON array of strings'); + } + + const scope = args.scope || 'both'; + logDebugEvent('research.start', { queryCount: queries.length, scope }); + + const results: Array<{ query: string; brain?: unknown[]; web?: unknown[] }> = []; + + for (const query of queries) { + const entry: { query: string; brain?: unknown[]; web?: unknown[] } = { query }; + + const promises: Promise[] = []; + + if (scope === 'brain' || scope === 'both') { + promises.push( + (async () => { + try { + const result = await ctx.callTool('brain_search', { query, limit: 5 }); + entry.brain = Array.isArray(result) ? result : [result]; + } catch { + entry.brain = []; + } + })() + ); + } + + if (scope === 'web' || scope === 'both') { + promises.push( + (async () => { + try { + const result = await ctx.callTool('websearch', { query }); + entry.web = Array.isArray(result) ? result : [result]; + } catch { + entry.web = []; + } + })() + ); + } + + await Promise.all(promises); + results.push(entry); + } + + logDebugEvent('research.complete', { count: results.length }); + return results; + }, +}); diff --git a/src/tools/smart-edit.ts b/src/tools/smart-edit.ts new file mode 100644 index 0000000..6299cfb --- /dev/null +++ b/src/tools/smart-edit.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { logDebugEvent } from '../lib/debug-logger'; + +export const smartEditTool = tool({ + description: `Replace text in a file with whitespace-tolerant fuzzy matching. Tries exact match first, then retries with normalized whitespace per line. Use when native edit fails due to indentation variance.`, + + args: { + file_path: tool.schema + .string() + .describe('Absolute path to the file'), + old_string: tool.schema + .string() + .describe('Text to find and replace'), + new_string: tool.schema + .string() + .describe('Replacement text'), + allow_multiple: tool.schema + .boolean() + .optional() + .describe('If false (default), error when >1 match found. If true, replace all matches.'), + }, + + async execute(args, _ctx) { + const { file_path, old_string, new_string, allow_multiple } = args; + + logDebugEvent('smart_edit.start', { file_path, old_string: old_string.substring(0, 40) }); + + if (!existsSync(file_path)) { + throw new Error(`File not found: ${file_path}`); + } + + const content = readFileSync(file_path, 'utf-8'); + const lines = content.split('\n'); + const oldLines = old_string.split('\n'); + + // Normalize: trim leading whitespace per line + const normalizeLine = (s: string) => s.replace(/^[ \t]+/, ''); + const normalizeBlock = (s: string) => s.split('\n').map(normalizeLine).join('\n'); + + // Count exact matches + const exactMatches = content.split(old_string).length - 1; + if (exactMatches > 0) { + if (exactMatches > 1 && !allow_multiple) { + logDebugEvent('smart_edit.multiple_exact', { file_path, matchCount: exactMatches }); + throw new Error( + `Found ${exactMatches} exact matches. Set allow_multiple=true to replace all, or narrow your search.` + ); + } + + const newContent = allow_multiple + ? content.replaceAll(old_string, new_string) + : content.replace(old_string, new_string); + writeFileSync(file_path, newContent, 'utf-8'); + const firstLine = content.substring(0, content.indexOf(old_string)).split('\n').length; + logDebugEvent('smart_edit.complete', { file_path, method: 'exact', matches: exactMatches }); + return { changed: true, line: firstLine, matches: exactMatches, method: 'exact' }; + } + + // Try normalized match + const normalizedOld = normalizeBlock(old_string); + let matchCount = 0; + let firstMatchLine = -1; + const candidateLines: number[] = []; + + for (let i = 0; i <= lines.length - oldLines.length; i++) { + const slice = lines.slice(i, i + oldLines.length); + const normalizedSlice = slice.map(normalizeLine).join('\n'); + if (normalizedSlice === normalizedOld) { + if (matchCount === 0) firstMatchLine = i + 1; + matchCount++; + candidateLines.push(i + 1); + } + } + + if (matchCount === 0) { + logDebugEvent('smart_edit.not_found', { file_path }); + throw new Error(`Text not found in ${file_path} (tried exact and whitespace-normalized match)`); + } + + if (matchCount > 1 && !allow_multiple) { + logDebugEvent('smart_edit.multiple_matches', { file_path, matchCount }); + throw new Error( + `Found ${matchCount} matches. Set allow_multiple=true to replace all, or narrow your search. Candidates at lines: ${candidateLines.join(', ')}` + ); + } + + // Apply replacement — work bottom-up to preserve line indices + let resultLines = [...lines]; + for (let i = resultLines.length - oldLines.length; i >= 0; i--) { + const slice = resultLines.slice(i, i + oldLines.length); + const normalizedSlice = slice.map(normalizeLine).join('\n'); + if (normalizedSlice === normalizedOld) { + const newLines = new_string.split('\n'); + resultLines.splice(i, oldLines.length, ...newLines); + } + } + + writeFileSync(file_path, resultLines.join('\n'), 'utf-8'); + logDebugEvent('smart_edit.complete', { file_path, method: 'normalized', matches: matchCount }); + return { changed: true, line: firstMatchLine, matches: matchCount, method: 'normalized' }; + }, +}); diff --git a/src/tools/smart-patch.ts b/src/tools/smart-patch.ts new file mode 100644 index 0000000..aadbef3 --- /dev/null +++ b/src/tools/smart-patch.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { logDebugEvent } from '../lib/debug-logger'; + +/** Simple unified diff parser — extracts context, additions, and deletions per hunk. */ +function parseSimpleDiff(patch: string): Array<{ context: string[]; additions: string[]; deletions: string[] }> { + const hunks: Array<{ context: string[]; additions: string[]; deletions: string[] }> = []; + const lines = patch.split('\n'); + let current: { context: string[]; additions: string[]; deletions: string[] } | null = null; + + for (const line of lines) { + if (line.startsWith('@@')) { + if (current) hunks.push(current); + current = { context: [], additions: [], deletions: [] }; + } else if (current) { + if (line.startsWith(' ')) current.context.push(line.substring(1)); + else if (line.startsWith('+')) current.additions.push(line.substring(1)); + else if (line.startsWith('-')) current.deletions.push(line.substring(1)); + } + } + if (current) hunks.push(current); + return hunks; +} + +export const smartPatchTool = tool({ + description: `Apply a unified diff patch to a file using context-anchored matching (ignores line numbers). Scans the file for the best match of context lines. Use when native patch fails due to line number drift.`, + + args: { + file_path: tool.schema.string().describe('Absolute path to the file'), + patch: tool.schema.string().describe('Unified diff patch to apply'), + fuzz: tool.schema + .number() + .optional() + .describe('Maximum mismatched lines allowed in context match (default: 3)'), + }, + + async execute(args, _ctx) { + const { file_path, patch, fuzz: fuzzRaw } = args; + const fuzz = fuzzRaw ?? 3; + + logDebugEvent('smart_patch.start', { file_path, patch_length: patch.length, fuzz }); + + if (!existsSync(file_path)) { + throw new Error(`File not found: ${file_path}`); + } + + const content = readFileSync(file_path, 'utf-8'); + const fileLines = content.split('\n'); + + const hunks = parseSimpleDiff(patch); + if (hunks.length === 0) { + throw new Error('No hunks found in patch'); + } + + // Process hunks in reverse (bottom-up) to avoid offset cascade + const reversedHunks = [...hunks].reverse(); + const offsets: number[] = []; + + for (const hunk of reversedHunks) { + if (hunk.context.length === 0 && hunk.deletions.length === 0) { + // Pure addition — append to end + offsets.unshift(fileLines.length); + fileLines.push(...hunk.additions); + continue; + } + + // Sliding window search for context match + let bestMatch = -1; + let bestMismatches = Infinity; + + const searchLen = hunk.context.length + hunk.deletions.length; + const maxStart = fileLines.length - searchLen; + + for (let i = 0; i <= maxStart; i++) { + let mismatches = 0; + + // Compare context lines + for (let j = 0; j < hunk.context.length; j++) { + if (fileLines[i + j] !== hunk.context[j]) { + mismatches++; + if (mismatches > fuzz) break; + } + } + + if (mismatches > fuzz) continue; + + // Compare deletion lines (lines being replaced) + for (let j = 0; j < hunk.deletions.length; j++) { + const idx = i + hunk.context.length + j; + if (idx < fileLines.length && fileLines[idx] !== hunk.deletions[j]) { + mismatches++; + if (mismatches > fuzz) break; + } + } + + if (mismatches < bestMismatches) { + bestMismatches = mismatches; + bestMatch = i; + if (mismatches === 0) break; // Perfect match + } + } + + if (bestMatch === -1) { + throw new Error( + `Could not match hunk context (${hunk.context.length} context + ${hunk.deletions.length} deletion lines). ` + + `Best mismatch: ${bestMismatches === Infinity ? 'none found' : bestMismatches} (fuzz=${fuzz})` + ); + } + + // Apply hunk at bestMatch — replace context+deletions with additions + const replacementLen = hunk.context.length + hunk.deletions.length; + fileLines.splice(bestMatch, replacementLen, ...hunk.additions); + offsets.unshift(bestMatch); + } + + writeFileSync(file_path, fileLines.join('\n'), 'utf-8'); + logDebugEvent('smart_patch.complete', { file_path, hunks: hunks.length }); + return { applied: true, hunks: hunks.length, offsets: offsets }; + }, +}); diff --git a/src/tools/solution-confidence.ts b/src/tools/solution-confidence.ts new file mode 100644 index 0000000..a0b6d53 --- /dev/null +++ b/src/tools/solution-confidence.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { logDebugEvent } from '../lib/debug-logger'; + +export const solutionConfidenceTool = tool({ + description: `Score how likely a fix actually resolved the problem. Runs tests, searches brain for matching KB patterns, and checks git blast radius coverage. Returns weighted confidence score.`, + + args: { + description: tool.schema.string().describe('Description of the fix — used to find relevant tests and KB entries'), + evidence: tool.schema + .string() + .optional() + .describe('Optional JSON array of evidence strings (e.g., test file paths, KB entry keys)'), + }, + + async execute(args, ctx) { + logDebugEvent('solution_confidence.start', { description: args.description.substring(0, 60) }); + + let testsPassed: boolean | null = null; + let kbMatch: boolean | null = null; + let coverageChecked: boolean | null = null; + const risks: string[] = []; + + // 1. Run tests — detect test files from description keywords + try { + const words = args.description.split(/\s+/).filter((w: string) => w.length > 3); + const testPattern = words.slice(0, 3).join('|'); + + try { + const testResult = await ctx.callTool('run_tests', { test_file: '.', filter: testPattern }); + if (testResult && typeof testResult === 'object') { + testsPassed = (testResult as Record).failures === 0; + } + } catch { + testsPassed = null; + } + } catch { + testsPassed = null; + } + + // 2. Search brain for matching KB patterns + try { + const kbResults = await ctx.callTool('brain_search', { query: args.description, limit: 3 }); + if (Array.isArray(kbResults) && kbResults.length > 0) { + const bestMatch = kbResults[0] as Record; + kbMatch = typeof bestMatch.score === 'number' && bestMatch.score > 0.7; + } else { + kbMatch = false; + } + } catch { + kbMatch = null; + } + + // 3. Git coverage check (pr_risk) + try { + const prResult = await ctx.callTool('pr_risk', {}); + coverageChecked = prResult !== undefined; + if (prResult && typeof prResult === 'object') { + const riskLevel = (prResult as Record).risk_level; + if (riskLevel === 'high') { + risks.push('High blast radius — uncommitted changes touch high-risk files'); + } + } + } catch { + coverageChecked = null; + } + + // Weighted scoring + const weights = { tests: 0.4, kb: 0.3, coverage: 0.3 }; + let score = 0; + if (testsPassed === true) score += weights.tests; + if (testsPassed === false) score += 0; + if (kbMatch === true) score += weights.kb; + if (coverageChecked === true) score += weights.coverage; + + // If any check is null, redistribute weight proportionally + const activeChecks = [testsPassed !== null, kbMatch !== null, coverageChecked !== null].filter(Boolean).length; + if (activeChecks > 0 && activeChecks < 3) { + score = score * (3 / activeChecks); + } + + let verdict: 'likely_fixed' | 'uncertain' | 'band_aid'; + if (score >= 0.75) verdict = 'likely_fixed'; + else if (score >= 0.45) verdict = 'uncertain'; + else verdict = 'band_aid'; + + logDebugEvent('solution_confidence.complete', { score, verdict }); + return { + confidence: Math.round(score * 100) / 100, + verdict, + risks, + checks: { tests: testsPassed, kb_match: kbMatch, coverage: coverageChecked }, + }; + }, +}); diff --git a/tests/batch-patch.test.ts b/tests/batch-patch.test.ts new file mode 100644 index 0000000..b7e0b11 --- /dev/null +++ b/tests/batch-patch.test.ts @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { batchPatchTool } from '../src/tools/batch-patch'; + +function mockCtx() { + return { + sessionID: 'test-session', + messageID: 'test-message', + agent: 'test-agent', + directory: '/tmp', + worktree: '/tmp', + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + }; +} + +describe('batch_patch tool', () => { + let file1: string; + let file2: string; + + beforeEach(() => { + const base = join(tmpdir(), `batch-patch-${Date.now()}`); + file1 = `${base}-1.txt`; + file2 = `${base}-2.txt`; + writeFileSync(file1, 'hello world', 'utf-8'); + writeFileSync(file2, 'foo bar', 'utf-8'); + }); + + afterEach(() => { + try { + if (existsSync(file1)) unlinkSync(file1); + if (existsSync(file2)) unlinkSync(file2); + } catch { + /* ignore */ + } + }); + + it('applies patches to multiple files', async () => { + const patches = JSON.stringify([ + { file_path: file1, patch: '@@ -1 +1 @@\n-hello world\n+hello universe' }, + { file_path: file2, patch: '@@ -1 +1 @@\n-foo bar\n+foo baz' }, + ]); + + const result = await batchPatchTool.execute({ patches }, mockCtx()); + expect(result.applied).toHaveLength(2); + expect(result.failed).toHaveLength(0); + expect(readFileSync(file1, 'utf-8')).toContain('hello universe'); + expect(readFileSync(file2, 'utf-8')).toContain('foo baz'); + }); + + it('reports failure on bad patch', async () => { + const patches = JSON.stringify([ + { file_path: file1, patch: '@@ -1 +1 @@\n-hello universe\n+hi' }, + { file_path: '/tmp/nonexistent-batch-patch-test.txt', patch: 'bad' }, + ]); + + const result = await batchPatchTool.execute({ patches }, mockCtx()); + expect(result.failed.length).toBeGreaterThan(0); + expect(result.applied).toHaveLength(1); // First one still applies + }); + + it('rolls back on atomic mode', async () => { + const original1 = readFileSync(file1, 'utf-8'); + const patches = JSON.stringify([ + { file_path: file1, patch: '@@ -1 +1 @@\n-hello world\n+hello universe' }, + { file_path: '/tmp/nonexistent-atomic-rollback.txt', patch: 'bad patch' }, + ]); + + const result = await batchPatchTool.execute({ patches, atomic: true }, mockCtx()); + expect(result.failed.length).toBeGreaterThan(0); + // file1 should be rolled back + expect(readFileSync(file1, 'utf-8')).toBe(original1); + }); +}); diff --git a/tests/file-tree.test.ts b/tests/file-tree.test.ts new file mode 100644 index 0000000..4ff629c --- /dev/null +++ b/tests/file-tree.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileTreeTool } from '../src/tools/file-tree'; + +function mockCtx() { + return { + sessionID: 'test-session', + messageID: 'test-message', + agent: 'test-agent', + directory: '/tmp', + worktree: '/tmp', + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + }; +} + +describe('file_tree tool', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `file-tree-test-${Date.now()}`); + rmSync(testDir, { recursive: true, force: true }); + mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, 'subdir')); + writeFileSync(join(testDir, 'file1.txt'), 'hello', 'utf-8'); + writeFileSync(join(testDir, 'subdir', 'file2.ts'), 'const x = 1;', 'utf-8'); + }); + + afterEach(() => { + try { + if (existsSync(testDir)) rmSync(testDir, { recursive: true }); + } catch { + /* ignore */ + } + }); + + it('lists directory tree', async () => { + const result = await fileTreeTool.execute({ path: testDir, depth: 2 }, mockCtx()); + expect(Array.isArray(result)).toBe(true); + expect(result.some((n: { name: string }) => n.name === 'file1.txt')).toBe(true); + expect(result.some((n: { name: string }) => n.name === 'subdir/')).toBe(true); + }); + + it('returns file info for single file path', async () => { + const result = await fileTreeTool.execute({ path: join(testDir, 'file1.txt') }, mockCtx()); + expect(Array.isArray(result)).toBe(true); + expect(result[0].type).toBe('file'); + expect(result[0].size).toBe(5); + }); + + it('throws on nonexistent path', async () => { + expect(fileTreeTool.execute({ path: '/tmp/nonexistent-file-tree' }, mockCtx())).rejects.toThrow('Path not found'); + }); +}); diff --git a/tests/research.test.ts b/tests/research.test.ts new file mode 100644 index 0000000..d28c352 --- /dev/null +++ b/tests/research.test.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { describe, it, expect } from 'bun:test'; +import { researchTool } from '../src/tools/research'; + +describe('research tool', () => { + it('parses queries and returns structure', async () => { + const ctx = { + sessionID: 'test-session', + messageID: 'test-message', + agent: 'test-agent', + directory: '/tmp', + worktree: '/tmp', + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + callTool: async (name: string, _args: Record) => { + if (name === 'brain_search') return [{ content: 'brain result', score: 1.0 }]; + if (name === 'websearch') return [{ title: 'web result', url: 'https://example.com' }]; + return []; + }, + }; + + const result = await researchTool.execute( + { queries: JSON.stringify(['test query']), scope: 'both' }, + ctx as any + ); + + expect(Array.isArray(result)).toBe(true); + expect(result[0].query).toBe('test query'); + expect(result[0].brain).toBeDefined(); + expect(result[0].web).toBeDefined(); + }); + + it('handles brain-only scope', async () => { + const ctx = { + callTool: async (name: string, _args: Record) => { + if (name === 'brain_search') return [{ content: 'brain result', score: 0.9 }]; + return []; + }, + } as any; + + const result = await researchTool.execute( + { queries: JSON.stringify(['brain query']), scope: 'brain' }, + ctx + ); + + expect(result[0].brain).toBeDefined(); + expect(result[0].web).toBeUndefined(); + }); +}); diff --git a/tests/smart-edit.test.ts b/tests/smart-edit.test.ts new file mode 100644 index 0000000..1be73e5 --- /dev/null +++ b/tests/smart-edit.test.ts @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { smartEditTool } from '../src/tools/smart-edit'; + +function mockCtx() { + return { + sessionID: 'test-session', + messageID: 'test-message', + agent: 'test-agent', + directory: '/tmp', + worktree: '/tmp', + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + }; +} + +describe('smart_edit tool', () => { + let testFile: string; + + beforeEach(() => { + testFile = join(tmpdir(), `smart-edit-test-${Date.now()}.txt`); + writeFileSync(testFile, "function hello() {\n return 'world';\n}", 'utf-8'); + }); + + afterEach(() => { + try { + if (existsSync(testFile)) unlinkSync(testFile); + } catch { + /* ignore */ + } + }); + + it('exact match replaces text', async () => { + const result = await smartEditTool.execute( + { + file_path: testFile, + old_string: "return 'world'", + new_string: "return 'universe'", + }, + mockCtx() + ); + + expect(result.changed).toBe(true); + expect(result.method).toBe('exact'); + const content = readFileSync(testFile, 'utf-8'); + expect(content).toContain("return 'universe'"); + }); + + it('normalized match replaces with whitespace variance', async () => { + writeFileSync(testFile, "function hello() {\n\treturn 'world';\n}", 'utf-8'); + + const result = await smartEditTool.execute( + { + file_path: testFile, + old_string: " return 'world';", + new_string: " return 'mars';", + }, + mockCtx() + ); + + expect(result.changed).toBe(true); + expect(result.method).toBe('normalized'); + const content = readFileSync(testFile, 'utf-8'); + expect(content).toContain("return 'mars'"); + }); + + it('throws on file not found', async () => { + expect( + smartEditTool.execute( + { + file_path: '/tmp/nonexistent-file-12345.txt', + old_string: 'x', + new_string: 'y', + }, + mockCtx() + ) + ).rejects.toThrow('File not found'); + }); + + it('throws on multiple matches without allow_multiple', async () => { + writeFileSync(testFile, 'foo\nfoo\nbar\n', 'utf-8'); + + expect( + smartEditTool.execute( + { + file_path: testFile, + old_string: 'foo', + new_string: 'baz', + }, + mockCtx() + ) + ).rejects.toThrow('Found 2 exact matches'); + }); + + it('allows multiple matches when allow_multiple is true', async () => { + writeFileSync(testFile, 'foo\nfoo\nbar\n', 'utf-8'); + + const result = await smartEditTool.execute( + { + file_path: testFile, + old_string: 'foo', + new_string: 'baz', + allow_multiple: true, + }, + mockCtx() + ); + + expect(result.matches).toBe(2); + const content = readFileSync(testFile, 'utf-8'); + expect(content).toBe('baz\nbaz\nbar\n'); + }); +}); diff --git a/tests/smart-patch.test.ts b/tests/smart-patch.test.ts new file mode 100644 index 0000000..ecbb02f --- /dev/null +++ b/tests/smart-patch.test.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { smartPatchTool } from '../src/tools/smart-patch'; + +function mockCtx() { + return { + sessionID: 'test-session', + messageID: 'test-message', + agent: 'test-agent', + directory: '/tmp', + worktree: '/tmp', + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + }; +} + +describe('smart_patch tool', () => { + let testFile: string; + + beforeEach(() => { + testFile = join(tmpdir(), `smart-patch-test-${Date.now()}.txt`); + writeFileSync(testFile, 'line 1\nline 2\nline 3\nline 4\nline 5', 'utf-8'); + }); + + afterEach(() => { + try { + if (existsSync(testFile)) unlinkSync(testFile); + } catch { + /* ignore */ + } + }); + + it('applies simple patch with context', async () => { + const patch = '@@ -1,5 +1,5 @@\n line 1\n-line 2\n+line two\n line 3\n line 4\n line 5'; + const result = await smartPatchTool.execute( + { + file_path: testFile, + patch, + }, + mockCtx() + ); + + expect(result.applied).toBe(true); + expect(result.hunks).toBe(1); + const content = readFileSync(testFile, 'utf-8'); + expect(content).toContain('line two'); + }); + + it('applies patch even with wrong line numbers (context anchored)', async () => { + // Line numbers say @@ -10,5 but we match by context + const patch = '@@ -10,5 +10,5 @@\n line 2\n-line 3\n+line three\n line 4'; + const result = await smartPatchTool.execute( + { + file_path: testFile, + patch, + }, + mockCtx() + ); + + expect(result.applied).toBe(true); + const content = readFileSync(testFile, 'utf-8'); + expect(content).toContain('line three'); + }); + + it('throws on file not found', async () => { + expect( + smartPatchTool.execute( + { + file_path: '/tmp/nonexistent-patch-test.txt', + patch: '@@ -1 +1 @@\n-old\n+new', + }, + mockCtx() + ) + ).rejects.toThrow('File not found'); + }); + + it('throws on bad patch with no hunks', async () => { + expect( + smartPatchTool.execute( + { + file_path: testFile, + patch: 'not a valid patch', + }, + mockCtx() + ) + ).rejects.toThrow('No hunks found'); + }); +}); diff --git a/tests/solution-confidence.test.ts b/tests/solution-confidence.test.ts new file mode 100644 index 0000000..47830ac --- /dev/null +++ b/tests/solution-confidence.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { describe, it, expect } from 'bun:test'; +import { solutionConfidenceTool } from '../src/tools/solution-confidence'; + +describe('solution_confidence tool', () => { + it('returns score structure with all checks passing', async () => { + const ctx = { + sessionID: 'test-session', + messageID: 'test-message', + agent: 'test-agent', + directory: '/tmp', + worktree: '/tmp', + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + callTool: async (name: string, _args: Record) => { + if (name === 'run_tests') return { pass: 5, fail: 0, failures: 0 }; + if (name === 'brain_search') return [{ score: 0.8, content: 'match' }]; + if (name === 'pr_risk') return { risk_level: 'low' }; + return []; + }, + }; + + const result = await solutionConfidenceTool.execute( + { description: 'fixed login bug in auth controller' }, + ctx as any + ); + + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('verdict'); + expect(result).toHaveProperty('risks'); + expect(result).toHaveProperty('checks'); + expect(['likely_fixed', 'uncertain', 'band_aid']).toContain(result.verdict); + }); + + it('returns band_aid when all checks fail', async () => { + const ctx = { + callTool: async (_name: string, _args: Record) => { + throw new Error('all fail'); + }, + } as any; + + const result = await solutionConfidenceTool.execute( + { description: 'random change' }, + ctx + ); + + expect(result.verdict).toBe('band_aid'); + expect(result.confidence).toBe(0); + }); +});