diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d1bd06b..93106a4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -7,6 +7,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: oven-sh/setup-bun@v2 with: bun-version: latest diff --git a/eslint.config.js b/eslint.config.js index 6e8563a..16be59a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,10 @@ export default tseslint.config( process: 'readonly', Buffer: 'readonly', Bun: 'readonly', + Response: 'readonly', + fetch: 'readonly', + setTimeout: 'readonly', + RequestInit: 'readonly', }, }, plugins: { @@ -33,8 +37,10 @@ export default tseslint.config( ...tseslint.configs.recommended.rules, ...prettier.rules, 'prettier/prettier': 'error', + 'no-unused-vars': 'off', // use @typescript-eslint version '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], 'no-console': 'warn', }, } diff --git a/package.json b/package.json index eec9223..742cf00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-supertools", - "version": "0.4.0", + "version": "0.4.1", "description": "Token-efficient supertools for opencode agents — patch_file via unified diff and more", "type": "module", "exports": { @@ -25,6 +25,7 @@ "scripts": { "build": "bun build ./src/four-opencode-supertools.ts --outdir dist --target bun --format esm --external @opencode-ai/plugin", "test": "bun test", + "lint": "eslint src/", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/src/four-opencode-supertools.ts b/src/four-opencode-supertools.ts index 3ae3385..938b29c 100644 --- a/src/four-opencode-supertools.ts +++ b/src/four-opencode-supertools.ts @@ -25,6 +25,8 @@ 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'; const FourOpencodeSupertools: Plugin = async (_ctx) => { return { @@ -52,6 +54,8 @@ const FourOpencodeSupertools: Plugin = async (_ctx) => { gitlab_mr_status: gitlabMrStatusTool, gh_pr_create: ghPrCreateTool, gh_pr_comment: ghPrCommentTool, + gh_pr_review: ghPrReviewTool, + append_file: appendFileTool, }, }; }; diff --git a/src/lib/gh-utils.ts b/src/lib/gh-utils.ts index 88fcb92..ced5ae8 100644 --- a/src/lib/gh-utils.ts +++ b/src/lib/gh-utils.ts @@ -25,7 +25,7 @@ export interface GhExecResult { * 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 { +export async function runGh(args: string[], cwd: string, _timeout = 30000): Promise { let proc; try { proc = Bun.spawn(['gh', ...args], { diff --git a/src/lib/git-utils.ts b/src/lib/git-utils.ts index e039534..b962774 100644 --- a/src/lib/git-utils.ts +++ b/src/lib/git-utils.ts @@ -38,7 +38,7 @@ export interface BlameLine { * 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 { +export async function runGit(args: string[], cwd: string, _timeout = 30000): Promise { let proc; try { proc = Bun.spawn(['git', ...args], { diff --git a/src/lib/gitlab-utils.ts b/src/lib/gitlab-utils.ts index 12abc2a..4fa6257 100644 --- a/src/lib/gitlab-utils.ts +++ b/src/lib/gitlab-utils.ts @@ -27,7 +27,7 @@ export async function getGitLabProjectId(cwd: string): Promise { 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)?$/); + const match = url.match(/[/:]([^/]+\/[^.]+?)(?:\.git)?$/); if (match) { return encodeURIComponent(match[1]); } @@ -41,7 +41,7 @@ export async function getGitLabProjectId(cwd: string): Promise { export async function gitlabApi( path: string, method: 'GET' | 'POST' | 'PUT' = 'GET', - body?: object, + 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' }; @@ -53,7 +53,7 @@ export async function gitlabApi( method, headers: { 'PRIVATE-TOKEN': cfg.token, - 'Accept': 'application/json', + Accept: 'application/json', 'Content-Type': 'application/json', }, }; @@ -66,7 +66,7 @@ export async function gitlabApi( ok: response.ok, status: response.status, data, - error: response.ok ? undefined : (data?.message || `HTTP ${response.status}`), + error: response.ok ? undefined : (data as any)?.message || `HTTP ${response.status}`, }; } catch (err) { return { diff --git a/src/tools/append-file.ts b/src/tools/append-file.ts new file mode 100644 index 0000000..9201f5c --- /dev/null +++ b/src/tools/append-file.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025-2026 Four Bytes + +import { tool } from '@opencode-ai/plugin'; +import { logDebugEvent } from '../lib/debug-logger'; +import { readFile, writeFile } from 'node:fs/promises'; + +export const appendFileTool = tool({ + description: + 'Append or prepend text to a file. Use for simple file additions (changelogs, blogs, logs) where unified diff matching is unnecessary. Saves ~95% tokens vs. patch_file for append-only operations.', + + args: { + file_path: tool.schema.string().describe('Absolute path to the file'), + content: tool.schema.string().describe('Text to insert into the file'), + mode: tool.schema.string().optional().describe('"append" (default) or "prepend"'), + after_line: tool.schema + .number() + .optional() + .describe( + 'For prepend mode: insert after this line number (0-based; 0 = before first line, -1 = before last line). Ignored in append mode.' + ), + }, + + async execute(args, _ctx) { + logDebugEvent('append_file.start', { file_path: args.file_path, mode: args.mode }); + + try { + const content = await readFile(args.file_path, 'utf-8'); + const mode = args.mode || 'append'; + + let newContent: string; + if (content.length === 0) { + // Empty file — just write the content + newContent = args.content; + if (!newContent.endsWith('\n')) { + newContent += '\n'; + } + } else if (mode === 'prepend') { + const lines = content.split('\n'); + let insertAt = args.after_line ?? 0; + // -1 means before the last line + if (insertAt === -1) { + insertAt = Math.max(0, lines.length - 2); + } + // after_line=0 means position 0 (before first line). + // after_line=N (N>0) means after line at index N, so position N+1. + const insertPos = insertAt === 0 ? 0 : insertAt + 1; + const before = lines.slice(0, insertPos); + const after = lines.slice(insertPos); + newContent = [...before, args.content, ...after].join('\n'); + } else { + // Append mode + newContent = content.endsWith('\n') + ? content + args.content + : content + '\n' + args.content; + if (!newContent.endsWith('\n')) { + newContent += '\n'; + } + } + + await writeFile(args.file_path, newContent, 'utf-8'); + const lineCount = newContent.split('\n').length; + + logDebugEvent('append_file.done', { file_path: args.file_path, lines: lineCount }); + return `Appended to ${args.file_path} (${mode} mode, ${lineCount} lines total)`; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logDebugEvent('append_file.error', { error: msg }); + return `Error: ${msg}`; + } + }, +}); diff --git a/src/tools/apply-patch.ts b/src/tools/apply-patch.ts index 76636a9..17480ae 100644 --- a/src/tools/apply-patch.ts +++ b/src/tools/apply-patch.ts @@ -31,7 +31,7 @@ IMPORTANT: Always use this tool instead of \`write\` or \`edit\` when modifying ), }, - async execute(args, ctx) { + async execute(args, _ctx) { const { file_path, patch } = args; if (!file_path || typeof file_path !== 'string') { diff --git a/src/tools/curse-score.ts b/src/tools/curse-score.ts index b587e16..4119306 100644 --- a/src/tools/curse-score.ts +++ b/src/tools/curse-score.ts @@ -169,7 +169,7 @@ export function computeCurseScores( /** * Format curse score results as plain text. */ -function formatCurseScoreOutput(results: CurseResult[], top: number): string { +function formatCurseScoreOutput(results: CurseResult[], _top: number): string { const lines: string[] = []; lines.push(`CURSE SCORE — top ${results.length} files by risk`); diff --git a/src/tools/gh-pr-comment.ts b/src/tools/gh-pr-comment.ts index 4654e32..1d01dbb 100644 --- a/src/tools/gh-pr-comment.ts +++ b/src/tools/gh-pr-comment.ts @@ -11,7 +11,10 @@ export const ghPrCommentTool = tool({ 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)'), + repo: tool.schema + .string() + .optional() + .describe('GitHub repo in owner/repo format (defaults to current repo)'), }, async execute(args, ctx) { @@ -23,7 +26,10 @@ export const ghPrCommentTool = tool({ const resolvedRepo = await resolveRepo(repo, ctx.directory); const repoArgs = ['-R', resolvedRepo]; - const output = await runGh(['pr', 'comment', String(pr), ...repoArgs, '--body', body], ctx.directory); + 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()}`; diff --git a/src/tools/gh-pr-create.ts b/src/tools/gh-pr-create.ts index 6bc901d..432f632 100644 --- a/src/tools/gh-pr-create.ts +++ b/src/tools/gh-pr-create.ts @@ -14,7 +14,10 @@ export const ghPrCreateTool = tool({ 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)'), + repo: tool.schema + .string() + .optional() + .describe('GitHub repo in owner/repo format (defaults to current repo)'), }, async execute(args, ctx) { diff --git a/src/tools/gh-pr-review.ts b/src/tools/gh-pr-review.ts new file mode 100644 index 0000000..b8541c1 --- /dev/null +++ b/src/tools/gh-pr-review.ts @@ -0,0 +1,107 @@ +// 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 ?? []; + + // Filter out empty-body reviews (e.g. bare APPROVED clicks) + const substantiveReviews = reviews.filter((r) => r.body && r.body.trim().length > 0); + + if (comments.length === 0 && substantiveReviews.length === 0) { + return `No review comments on PR #${pr}.`; + } + + const lines: string[] = [`Review comments for PR #${pr}:`, '']; + + if (substantiveReviews.length > 0) { + lines.push('## Reviews'); + lines.push(''); + for (const review of substantiveReviews) { + const reviewer = review.author?.login ?? 'unknown'; + const state = review.state ?? 'COMMENTED'; + const ts = review.submittedAt ? ` (${review.submittedAt})` : ''; + const body = + review.body!.length > 500 ? `${review.body!.substring(0, 500)}…` : review.body!; + 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: substantiveReviews.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/gitlab-mr-comment.ts b/src/tools/gitlab-mr-comment.ts index 7600c0e..5245888 100644 --- a/src/tools/gitlab-mr-comment.ts +++ b/src/tools/gitlab-mr-comment.ts @@ -25,7 +25,7 @@ export const gitlabMrCommentTool = tool({ const result = await gitlabApi( `projects/${projectId}/merge_requests/${mrIid}/notes`, 'POST', - { body }, + { body } ); if (!result.ok) { diff --git a/src/tools/gitlab-mr-create.ts b/src/tools/gitlab-mr-create.ts index 3d968b1..d850276 100644 --- a/src/tools/gitlab-mr-create.ts +++ b/src/tools/gitlab-mr-create.ts @@ -24,16 +24,12 @@ export const gitlabMrCreateTool = tool({ 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 || '', - }, - ); + 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}`; diff --git a/src/tools/gitlab-mr-status.ts b/src/tools/gitlab-mr-status.ts index a66ecc1..252dbbb 100644 --- a/src/tools/gitlab-mr-status.ts +++ b/src/tools/gitlab-mr-status.ts @@ -9,7 +9,10 @@ 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.'), + mrIid: tool.schema + .number() + .optional() + .describe('MR internal ID (!number). If omitted, lists all open MRs for the project.'), }, async execute(args, ctx) { @@ -23,9 +26,7 @@ export const gitlabMrStatusTool = tool({ if (mrIid) { // Single MR - const result = await gitlabApi( - `projects/${projectId}/merge_requests/${mrIid}`, - ); + const result = await gitlabApi(`projects/${projectId}/merge_requests/${mrIid}`); if (!result.ok) return `Failed to get MR: ${result.error}`; @@ -42,7 +43,7 @@ export const gitlabMrStatusTool = tool({ } else { // List open MRs const result = await gitlabApi( - `projects/${projectId}/merge_requests?state=opened&per_page=10`, + `projects/${projectId}/merge_requests?state=opened&per_page=10` ); if (!result.ok) return `Failed to list MRs: ${result.error}`; @@ -52,7 +53,9 @@ export const gitlabMrStatusTool = tool({ 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})`); + lines.push( + ` !${mr.iid}: ${mr.title} [${mr.merge_status}] (${mr.source_branch} → ${mr.target_branch})` + ); } return lines.join('\n'); } diff --git a/src/tools/implicit-coupling.ts b/src/tools/implicit-coupling.ts index e5ca542..e9d232f 100644 --- a/src/tools/implicit-coupling.ts +++ b/src/tools/implicit-coupling.ts @@ -61,10 +61,8 @@ export function computeCoupling(commits: Commit[], threshold: number): CouplingR // Optimization: for repos with >500 changed files, sample top 500 most-changed files const MAX_FILES = 500; let sampledFiles: Set; - let truncated = false; if (fileCounts.size > MAX_FILES) { - truncated = true; 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 { diff --git a/src/tools/pr-risk.ts b/src/tools/pr-risk.ts index 2c8d228..5debc15 100644 --- a/src/tools/pr-risk.ts +++ b/src/tools/pr-risk.ts @@ -2,7 +2,7 @@ // Copyright (c) 2025-2026 Four Bytes import { tool } from '@opencode-ai/plugin'; -import { runGit, parseGitLog, type Commit } from '../lib/git-utils'; +import { runGit, parseGitLog } from '../lib/git-utils'; import { computeCurseScores } from './curse-score'; import { computeCoupling } from './implicit-coupling'; import { logDebugEvent } from '../lib/debug-logger'; diff --git a/tests/append-file.test.ts b/tests/append-file.test.ts new file mode 100644 index 0000000..fcd6f6c --- /dev/null +++ b/tests/append-file.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { appendFileTool } from '../src/tools/append-file'; + +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('append_file tool', () => { + let testDir: string; + let ctx: ReturnType; + + beforeEach(() => { + testDir = join(tmpdir(), `supertools-append-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + ctx = mockCtx(testDir); + }); + + afterEach(() => { + try { + rmSync(testDir, { recursive: true }); + } catch { + /* ignore */ + } + }); + + it('appends to an existing file', async () => { + const filePath = join(testDir, 'test.txt'); + writeFileSync(filePath, 'line1\nline2\n', 'utf-8'); + + const result = await appendFileTool.execute( + { + file_path: filePath, + content: 'line3\nline4', + mode: 'append', + }, + ctx + ); + + expect(result).toContain('Appended to'); + expect(result).toContain('append mode'); + + const content = readFileSync(filePath, 'utf-8'); + expect(content).toBe('line1\nline2\nline3\nline4\n'); + }); + + it('prepends at the top of an existing file', async () => { + const filePath = join(testDir, 'test.txt'); + writeFileSync(filePath, 'line1\nline2\n', 'utf-8'); + + const result = await appendFileTool.execute( + { + file_path: filePath, + content: 'header', + mode: 'prepend', + after_line: 0, + }, + ctx + ); + + expect(result).toContain('prepend mode'); + + const content = readFileSync(filePath, 'utf-8'); + expect(content).toBe('header\nline1\nline2\n'); + }); + + it('prepends after a specific line', async () => { + const filePath = join(testDir, 'test.txt'); + writeFileSync(filePath, 'line1\nline2\nline3\n', 'utf-8'); + + const result = await appendFileTool.execute( + { + file_path: filePath, + content: 'inserted', + mode: 'prepend', + after_line: 1, + }, + ctx + ); + + expect(result).toContain('prepend mode'); + + const content = readFileSync(filePath, 'utf-8'); + expect(content).toBe('line1\nline2\ninserted\nline3\n'); + }); + + it('appends to an empty file', async () => { + const filePath = join(testDir, 'test.txt'); + writeFileSync(filePath, '', 'utf-8'); + + const result = await appendFileTool.execute( + { + file_path: filePath, + content: 'first line', + mode: 'append', + }, + ctx + ); + + expect(result).toContain('Appended to'); + + const content = readFileSync(filePath, 'utf-8'); + expect(content).toBe('first line\n'); + }); + + it('prepends to an empty file', async () => { + const filePath = join(testDir, 'test.txt'); + writeFileSync(filePath, '', 'utf-8'); + + const result = await appendFileTool.execute( + { + file_path: filePath, + content: 'first line', + mode: 'prepend', + after_line: 0, + }, + ctx + ); + + expect(result).toContain('prepend mode'); + + const content = readFileSync(filePath, 'utf-8'); + expect(content).toBe('first line\n'); + }); + + it('returns error for missing file', async () => { + const filePath = join(testDir, 'nonexistent.txt'); + + const result = await appendFileTool.execute( + { + file_path: filePath, + content: 'hello', + mode: 'append', + }, + ctx + ); + + expect(result).toContain('Error'); + expect(result).toContain('ENOENT'); + }); +});