Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export default tseslint.config(
process: 'readonly',
Buffer: 'readonly',
Bun: 'readonly',
Response: 'readonly',
fetch: 'readonly',
setTimeout: 'readonly',
RequestInit: 'readonly',
},
},
plugins: {
Expand All @@ -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',
},
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions src/four-opencode-supertools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
},
};
};
Expand Down
2 changes: 1 addition & 1 deletion src/lib/gh-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
export async function runGh(args: string[], cwd: string, _timeout = 30000): Promise<string> {
let proc;
try {
proc = Bun.spawn(['gh', ...args], {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/git-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
export async function runGit(args: string[], cwd: string, _timeout = 30000): Promise<string> {
let proc;
try {
proc = Bun.spawn(['git', ...args], {
Expand Down
8 changes: 4 additions & 4 deletions src/lib/gitlab-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function getGitLabProjectId(cwd: string): Promise<string | null> {
const proc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], { cwd, stdout: 'pipe' });
const url = (await new Response(proc.stdout).text()).trim();
// Extract: <EMAIL_1>:group/project.git → group/project
const match = url.match(/[:\/]([^\/]+\/[^.]+?)(?:\.git)?$/);
const match = url.match(/[/:]([^/]+\/[^.]+?)(?:\.git)?$/);
if (match) {
return encodeURIComponent(match[1]);
}
Expand All @@ -41,7 +41,7 @@ export async function getGitLabProjectId(cwd: string): Promise<string | null> {
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' };
Expand All @@ -53,7 +53,7 @@ export async function gitlabApi(
method,
headers: {
'PRIVATE-TOKEN': cfg.token,
'Accept': 'application/json',
Accept: 'application/json',
'Content-Type': 'application/json',
},
};
Expand All @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions src/tools/append-file.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: after_line: -1 is placed at the wrong position in prepend mode. It can insert at the beginning (or after the last content line) instead of before the last line.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tools/append-file.ts, line 48:

<comment>`after_line: -1` is placed at the wrong position in prepend mode. It can insert at the beginning (or after the last content line) instead of before the last line.</comment>

<file context>
@@ -0,0 +1,73 @@
+        }
+        // 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);
</file context>

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}`;
}
},
});
2 changes: 1 addition & 1 deletion src/tools/apply-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
2 changes: 1 addition & 1 deletion src/tools/curse-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);

Expand Down
10 changes: 8 additions & 2 deletions src/tools/gh-pr-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()}`;
Expand Down
5 changes: 4 additions & 1 deletion src/tools/gh-pr-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
107 changes: 107 additions & 0 deletions src/tools/gh-pr-review.ts
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Review-state data is lost by filtering out bodyless reviews. Tool can report “No review comments” even when formal review decisions exist.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tools/gh-pr-review.ts, line 55:

<comment>Review-state data is lost by filtering out bodyless reviews. Tool can report “No review comments” even when formal review decisions exist.</comment>

<file context>
@@ -0,0 +1,107 @@
+      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) {
</file context>


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}`;
}
},
});
2 changes: 1 addition & 1 deletion src/tools/gitlab-mr-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const gitlabMrCommentTool = tool({
const result = await gitlabApi(
`projects/${projectId}/merge_requests/${mrIid}/notes`,
'POST',
{ body },
{ body }
);

if (!result.ok) {
Expand Down
16 changes: 6 additions & 10 deletions src/tools/gitlab-mr-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
Loading
Loading