From ca57901c2db1d2c440d7115b19ec5cb14ff78eec Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Mon, 26 Jan 2026 07:39:14 +0900 Subject: [PATCH] fix(response, review): fix P1-6, P1-7, P1-8 from Codex review - P1-6: Add threadId support to Discord reply (use threadId as target channel) - P1-7: Handle long input (>4000 chars) with temp files - P1-8: Capture tmux output with capture-pane instead of send-keys stdout --- src/response/discord-reply.ts | 35 +++++----- src/review/codex-reviewer.ts | 122 +++++++++++++++++++++++++++------- 2 files changed, 118 insertions(+), 39 deletions(-) diff --git a/src/response/discord-reply.ts b/src/response/discord-reply.ts index d9916092e..62150ac3a 100644 --- a/src/response/discord-reply.ts +++ b/src/response/discord-reply.ts @@ -129,7 +129,10 @@ function buildEmbeds(embeds: ReplyEmbed[]): Record[] { * @param options - オプション */ export async function sendReply(replyData: ReplyData, options: DiscordReplyOptions): Promise { - const { api, channelId, messageId } = options; + const { api, channelId, messageId, threadId } = options; + + // P1-6修正: スレッド内返信の場合はthreadIdをチャンネルとして使用 + const targetChannelId = threadId || channelId; // 引用メタデータ構築 const quote: QuoteMetadata = { @@ -157,10 +160,19 @@ export async function sendReply(replyData: ReplyData, options: DiscordReplyOptio // メンション設定 const allowedMentions = buildAllowedMentions(options); + // P1-6修正: message_referenceにthread_idを追加 + const messageReference: Record = { + channel_id: channelId, + message_id: messageId, + }; + if (threadId) { + messageReference.thread_id = threadId; + } + // ファイル添付がある場合 if (options.fileUrls && options.fileUrls.length > 0) { // 最初のメッセージでテキスト+最初のファイル - await api.rest.post(`/channels/${channelId}/messages`, { + await api.rest.post(`/channels/${targetChannelId}/messages`, { content, allowed_mentions: allowedMentions, attachments: options.fileUrls.slice(0, 1).map((url, i) => ({ @@ -168,15 +180,12 @@ export async function sendReply(replyData: ReplyData, options: DiscordReplyOptio description: `artifact-${i}`, url, })), - message_reference: { - channel_id: channelId, - message_id: messageId, - }, + message_reference: messageReference, }); // 追加ファイルを別メッセージで送信 for (const fileUrl of options.fileUrls.slice(1)) { - await api.rest.post(`/channels/${channelId}/messages`, { + await api.rest.post(`/channels/${targetChannelId}/messages`, { content: "", attachments: [ { @@ -192,25 +201,21 @@ export async function sendReply(replyData: ReplyData, options: DiscordReplyOptio // Embedがある場合 if (options.embeds && options.embeds.length > 0) { - await api.rest.post(`/channels/${channelId}/messages`, { + await api.rest.post(`/channels/${targetChannelId}/messages`, { content, allowed_mentions: allowedMentions, embeds: buildEmbeds(options.embeds), - message_reference: { - channel_id: channelId, - message_id: messageId, - }, + message_reference: messageReference, }); return; } // シンプルテキスト返信 - await api.rest.post(`/channels/${channelId}/messages`, { + await api.rest.post(`/channels/${targetChannelId}/messages`, { content, allowed_mentions: allowedMentions, message_reference: { - channel_id: channelId, - message_id: messageId, + ...messageReference, fail_if_not_exists: false, }, }); diff --git a/src/review/codex-reviewer.ts b/src/review/codex-reviewer.ts index 66c5196b3..55528af69 100644 --- a/src/review/codex-reviewer.ts +++ b/src/review/codex-reviewer.ts @@ -6,6 +6,9 @@ import { exec } from "child_process"; import { promisify } from "util"; +import { writeFileSync, unlinkSync } from "fs"; +import { mkdtempSync } from "fs"; +import { join } from "path"; import type { CodexReview, ReviewRequest, @@ -29,6 +32,9 @@ const DEFAULT_TIMEOUT = 5 * 60 * 1000; /** デフォルトtmuxターゲット (MacBook用) */ const DEFAULT_TMUX_TARGET = "%2"; // カエデ (CodeGen) のペイン +/** P1-7修正: 一時ファイルの最大サイズ (4000文字) */ +const MAX_COMMAND_LENGTH = 4000; + /** * シェルコマンド用に文字列をエスケープ * tmux send-keys に安全に渡すためのエスケープ処理 @@ -50,6 +56,8 @@ function escapeShellString(str: string): string { /** * tmuxコマンドを実行 * + * P1-8修正: capture-paneで出力を取得する + * * @param command - 実行するコマンド * @param target - tmuxターゲット (ペインID) * @param options - オプション @@ -62,21 +70,32 @@ async function execTmux( ): Promise { const { timeout = DEFAULT_TIMEOUT, env = {} } = options; - // tmux send-keys でコマンドを送信 (コマンドインジェクション対策) - const escapedCommand = escapeShellString(command); - const sendCommand = `tmux send-keys -t ${target} "${escapedCommand}" Enter`; - try { - // タイムアウト付きで実行 - const { stdout, stderr } = await execAsync(sendCommand, { + // tmux send-keys でコマンドを送信 (コマンドインジェクション対策) + const escapedCommand = escapeShellString(command); + const sendCommand = `tmux send-keys -t ${target} "${escapedCommand}" Enter`; + + // コマンド送信 + await execAsync(sendCommand, { timeout, env: { ...process.env, ...env }, }); + // P1-8修正: 実行完了を待ってから出力をキャプチャ + // コマンド実行に十分な時間を待つ(デフォルトでtimeoutの80%を待機) + const waitTime = Math.min(timeout * 0.8, 30000); // 最大30秒待機 + await sleep(waitTime); + + // capture-paneでペインの内容を取得 + const captureCommand = `tmux capture-pane -t ${target} -p -S -`; + const { stdout: captured } = await execAsync(captureCommand, { + timeout: 5000, + }); + return { success: true, - stdout: stdout.trim(), - stderr: stderr.trim(), + stdout: captured.trim(), + stderr: "", exitCode: 0, }; } catch (error: unknown) { @@ -90,9 +109,18 @@ async function execTmux( } } +/** + * 指定ミリ秒待機する + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + /** * Codexでコードレビューを実行 * + * P1-7修正: 一時ファイルのクリーンアップ処理を追加 + * * @param content - レビュー対象コード * @param options - オプション * @returns レビュー結果 @@ -103,11 +131,10 @@ export async function runCodexReview( ): Promise { const startTime = Date.now(); - try { - // Codexに送信するコマンド構築 - // 既存のスキルやコマンドを使用 - const command = buildCodexCommand(content, options); + // P1-7修正: buildCodexCommandはcleanup関数を返す場合がある + const { command, cleanup } = buildCodexCommand(content, options); + try { // tmux経由でCodexを実行 const result = await execTmux(command, options.tmuxTarget, { timeout: options.timeout ?? DEFAULT_TIMEOUT, @@ -138,30 +165,77 @@ export async function runCodexReview( error: error instanceof Error ? error.message : String(error), duration, }; + } finally { + // P1-7修正: 一時ファイルをクリーンアップ + cleanup?.(); } } /** * Codexコマンドを構築 * + * P1-7修正: 長い入力は一時ファイル経由で処理 + * * @param content - レビュー対象コード * @param options - オプション - * @returns コマンド文字列 + * @returns コマンド文字列とクリーンアップ関数 */ -function buildCodexCommand(content: string, options: ReviewOptions): string { - // コンテンツを一時ファイルに保存または引数として渡す - // 長いコードの場合はファイル経由が安全 +function buildCodexCommand( + content: string, + options: ReviewOptions, +): { command: string; cleanup?: () => void } { + // P1-7修正: 長いコードは一時ファイルに書き出し + if (content.length > MAX_COMMAND_LENGTH) { + // 一時ディレクトリを作成 + const tempDir = mkdtempSync("/tmp/codex-review-"); + const tempFile = join(tempDir, "code.txt"); - const maxLength = 1000; - const useFile = content.length > maxLength; + try { + // コンテンツをファイルに書き出し + writeFileSync(tempFile, content, "utf-8"); - if (useFile) { - // TODO: 一時ファイルを使用する場合 - // 今回は簡易的に引数渡し (コマンドインジェクション対策) - const escapedContent = escapeShellString(content); - return `codex review "${escapedContent}"`; + // オプションを付与 + const opts: string[] = []; + if (options.threshold) { + opts.push(`--threshold ${options.threshold}`); + } + if (options.issuesOnly) { + opts.push("--issues-only"); + } + if (options.suggestionsOnly) { + opts.push("--suggestions-only"); + } + if (options.verbose) { + opts.push("--verbose"); + } + + const cmd = `codex review ${opts.join(" ")} -f ${escapeShellString(tempFile)}`; + + // クリーンアップ関数を返す + return { + command: cmd, + cleanup: () => { + try { + unlinkSync(tempFile); + } catch { + // クリーンアップエラーは無視 + } + try { + // 一時ディレクトリの削除(rmdirは空の場合のみ成功) + const { rmdirSync } = require("fs"); + rmdirSync(tempDir); + } catch { + // ディレクトリが空でない場合は無視 + } + }, + }; + } catch (error) { + // ファイル書き込みエラー時はフォールバック + console.warn("[CodexReviewer] Failed to write temp file, using inline content"); + } } + // 短いコンテンツは引数渡し // オプションを付与 const opts: string[] = []; if (options.threshold) { @@ -180,7 +254,7 @@ function buildCodexCommand(content: string, options: ReviewOptions): string { // コマンドインジェクション対策: contentをエスケープしてクォート const escapedContent = escapeShellString(content); const cmd = `codex review ${opts.join(" ")} "${escapedContent}"`; - return cmd; + return { command: cmd }; } /**