diff --git a/boomtick-mcp/src/lib/error_utils.ts b/boomtick-mcp/src/lib/error_utils.ts new file mode 100644 index 0000000000..962865ddb7 --- /dev/null +++ b/boomtick-mcp/src/lib/error_utils.ts @@ -0,0 +1,8 @@ +/** + * Sanitizes stderr from CLI commands to prevent implementation detail leakage + * in error messages returned to the AI agent. + */ +export function sanitizeError(stderr: string): string { + // Take first line and truncate to 200 chars to balance detail vs security + return (stderr.split("\n")[0] || "Unknown error").slice(0, 200); +} diff --git a/boomtick-mcp/src/mcp/server.ts b/boomtick-mcp/src/mcp/server.ts index bd5b7a7596..f49c8c9da2 100644 --- a/boomtick-mcp/src/mcp/server.ts +++ b/boomtick-mcp/src/mcp/server.ts @@ -27,6 +27,9 @@ import { runPlaywrightHandler, RunPlaywrightInputSchema } from "../tools/repo.ru import { commitPatchHandler, CommitPatchInputSchema } from "../tools/repo.commit_patch.js"; import { openReplacementPrHandler, OpenReplacementPrInputSchema } from "../tools/github.open_replacement_pr.js"; import { commentTriageSummaryHandler, CommentTriageSummaryInputSchema } from "../tools/github.comment_triage_summary.js"; +import { issueViewHandler, IssueViewInputSchema } from "../tools/github.issue_view.js"; +import { issueUpdateHandler, IssueUpdateInputSchema } from "../tools/github.issue_update.js"; +import { issueCommentHandler, IssueCommentInputSchema } from "../tools/github.issue_comment.js"; import { createJulesSessionHandler, CreateJulesSessionInputSchema } from "../tools/jules/create-session.js"; @@ -439,6 +442,41 @@ export class BoomtickMCPServer { required: ["prNumber", "body"], }, }, + { + name: "github.issue_view", + description: "View details of a GitHub issue including title, body, and state.", + inputSchema: { + type: "object", + properties: { + issueNumber: { type: "number", description: "The number of the issue to view." }, + }, + required: ["issueNumber"], + }, + }, + { + name: "github.issue_update", + description: "Update the body of a GitHub issue.", + inputSchema: { + type: "object", + properties: { + issueNumber: { type: "number", description: "The number of the issue to update." }, + body: { type: "string", description: "The new body content for the issue." }, + }, + required: ["issueNumber", "body"], + }, + }, + { + name: "github.issue_comment", + description: "Add a new comment to a GitHub issue.", + inputSchema: { + type: "object", + properties: { + issueNumber: { type: "number", description: "The number of the issue to comment on." }, + body: { type: "string", description: "The content of the comment." }, + }, + required: ["issueNumber", "body"], + }, + }, { name: "jules.create_session", description: "Create a Jules session that performs work externally and may generate a GitHub pull request.", @@ -584,6 +622,12 @@ export class BoomtickMCPServer { return createSuccessResult(await openReplacementPrHandler(OpenReplacementPrInputSchema.parse(request.params.arguments))); case "github.comment_triage_summary": return createSuccessResult(await commentTriageSummaryHandler(CommentTriageSummaryInputSchema.parse(request.params.arguments))); + case "github.issue_view": + return createSuccessResult(await issueViewHandler(IssueViewInputSchema.parse(request.params.arguments))); + case "github.issue_update": + return createSuccessResult(await issueUpdateHandler(IssueUpdateInputSchema.parse(request.params.arguments))); + case "github.issue_comment": + return createSuccessResult(await issueCommentHandler(IssueCommentInputSchema.parse(request.params.arguments))); diff --git a/boomtick-mcp/src/tools/github.issue_comment.ts b/boomtick-mcp/src/tools/github.issue_comment.ts new file mode 100644 index 0000000000..7761e7981a --- /dev/null +++ b/boomtick-mcp/src/tools/github.issue_comment.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { runCommand } from "../lib/shell.js"; +import { sanitizeError } from "../lib/error_utils.js"; + +export const IssueCommentInputSchema = z.object({ + issueNumber: z.number().describe("The number of the issue to comment on."), + body: z.string().min(1, "Comment body cannot be empty").describe("The content of the comment."), +}); + +const IssueCommentOutputSchema = z.object({ + status: z.string(), + comment: z.any().optional(), + message: z.string().optional(), +}); + +export async function issueCommentHandler(args: z.infer) { + const params = IssueCommentInputSchema.parse(args); + + const result = await runCommand("td-cli", ["gh", "issue-comment", params.issueNumber.toString(), "--body", params.body]); + + if (result.exitCode !== 0) { + throw new Error(`Failed to post comment: ${sanitizeError(result.stderr)}`); + } + + const output = IssueCommentOutputSchema.parse(JSON.parse(result.stdout)); + if (output.status === "error") { + throw new Error(`Failed to post comment: ${output.message}`); + } + + return { status: "success", comment: output.comment }; +} diff --git a/boomtick-mcp/src/tools/github.issue_update.ts b/boomtick-mcp/src/tools/github.issue_update.ts new file mode 100644 index 0000000000..a243f9bdfd --- /dev/null +++ b/boomtick-mcp/src/tools/github.issue_update.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { runCommand } from "../lib/shell.js"; +import { sanitizeError } from "../lib/error_utils.js"; + +export const IssueUpdateInputSchema = z.object({ + issueNumber: z.number().describe("The number of the issue to update."), + body: z.string().min(1, "Issue body cannot be empty").describe("The new body content for the issue."), +}); + +const IssueUpdateOutputSchema = z.object({ + status: z.string(), + issue: z.any().optional(), + message: z.string().optional(), +}); + +export async function issueUpdateHandler(args: z.infer) { + const params = IssueUpdateInputSchema.parse(args); + + const result = await runCommand("td-cli", ["gh", "issue-update", params.issueNumber.toString(), "--body", params.body]); + + if (result.exitCode !== 0) { + throw new Error(`Failed to update issue: ${sanitizeError(result.stderr)}`); + } + + const output = IssueUpdateOutputSchema.parse(JSON.parse(result.stdout)); + if (output.status === "error") { + throw new Error(`Failed to update issue: ${output.message}`); + } + + return { status: "success", issue: output.issue }; +} diff --git a/boomtick-mcp/src/tools/github.issue_view.ts b/boomtick-mcp/src/tools/github.issue_view.ts new file mode 100644 index 0000000000..01ee5efc67 --- /dev/null +++ b/boomtick-mcp/src/tools/github.issue_view.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { runCommand } from "../lib/shell.js"; +import { sanitizeError } from "../lib/error_utils.js"; + +export const IssueViewInputSchema = z.object({ + issueNumber: z.number().describe("The number of the issue to view."), +}); + +const IssueViewOutputSchema = z.object({ + status: z.string(), + issue: z.object({ + number: z.number(), + title: z.string(), + body: z.string().nullable().optional(), + state: z.string(), + }).optional(), + message: z.string().optional(), +}); + +export async function issueViewHandler(args: z.infer) { + const params = IssueViewInputSchema.parse(args); + + const result = await runCommand("td-cli", ["gh", "issue-view", params.issueNumber.toString()]); + + if (result.exitCode !== 0) { + throw new Error(`Failed to view issue: ${sanitizeError(result.stderr)}`); + } + + const output = IssueViewOutputSchema.parse(JSON.parse(result.stdout)); + if (output.status === "error") { + throw new Error(`Failed to view issue: ${output.message}`); + } + + return { issue: output.issue }; +} diff --git a/dev-tools/cli-schema.json b/dev-tools/cli-schema.json index ee572192b7..d2362bf445 100644 --- a/dev-tools/cli-schema.json +++ b/dev-tools/cli-schema.json @@ -88,6 +88,21 @@ } ] }, + "gh post-comment": { + "description": "Adds a new comment to a Pull Request from a file or literal text.", + "exact_usage": "python3 dev-tools/td_cli.py gh post-comment --pr [--file | --body ]", + "required_flags": [ + { + "flag": "--pr", + "type": "integer", + "description": "The GitHub Pull Request ID." + } + ], + "optional_flags": [ + { "flag": "--file", "type": "string", "description": "Path to the Markdown file containing the comment body." }, + { "flag": "--body", "type": "string", "description": "The literal body text for the comment." } + ] + }, "gh detect-conflicts": { "description": "Detects conflicts for a single, specific Pull Request.", "exact_usage": "python3 dev-tools/td_cli.py gh detect-conflicts --pr ", @@ -194,11 +209,43 @@ "required_flags": [] }, "gh create-issue": { - "description": "Creates a new GitHub issue using a title and a markdown file for the body.", - "exact_usage": "python3 dev-tools/td_cli.py gh create-issue --title --file <PATH_TO_BODY_FILE>", + "description": "Creates a new GitHub issue using a title and either a markdown file or literal text for the body.", + "exact_usage": "python3 dev-tools/td_cli.py gh create-issue --title <TITLE> [--file <PATH> | --body <TEXT>]", "required_flags": [ - { "flag": "--title", "type": "string", "description": "The title of the issue." }, - { "flag": "--file", "type": "string", "description": "Path to the Markdown file containing the issue body." } + { "flag": "--title", "type": "string", "description": "The title of the issue." } + ], + "optional_flags": [ + { "flag": "--file", "type": "string", "description": "Path to the Markdown file containing the issue body." }, + { "flag": "--body", "type": "string", "description": "The literal body text for the issue." } + ] + }, + "gh issue-view": { + "description": "Fetches details of a GitHub issue including title, body, and state.", + "exact_usage": "python3 dev-tools/td_cli.py gh issue-view <ISSUE_NUMBER>", + "required_arguments": [ + { "name": "issue_number", "type": "integer", "description": "The GitHub issue number." } + ] + }, + "gh issue-update": { + "description": "Updates the body of a GitHub issue from a file or literal text.", + "exact_usage": "python3 dev-tools/td_cli.py gh issue-update <ISSUE_NUMBER> [--file <PATH> | --body <TEXT>]", + "required_arguments": [ + { "name": "issue_number", "type": "integer", "description": "The GitHub issue number." } + ], + "optional_flags": [ + { "flag": "--file", "type": "string", "description": "Path to the Markdown file containing the new issue body." }, + { "flag": "--body", "type": "string", "description": "The literal body text for the issue." } + ] + }, + "gh issue-comment": { + "description": "Adds a new comment to a GitHub issue from a file or literal text.", + "exact_usage": "python3 dev-tools/td_cli.py gh issue-comment <ISSUE_NUMBER> [--file <PATH> | --body <TEXT>]", + "required_arguments": [ + { "name": "issue_number", "type": "integer", "description": "The GitHub issue number." } + ], + "optional_flags": [ + { "flag": "--file", "type": "string", "description": "Path to the Markdown file containing the comment body." }, + { "flag": "--body", "type": "string", "description": "The literal body text for the comment." } ] }, "gh validate-issue": { diff --git a/dev-tools/tdw_services/cli.py b/dev-tools/tdw_services/cli.py index ce9e6b01df..3007405276 100644 --- a/dev-tools/tdw_services/cli.py +++ b/dev-tools/tdw_services/cli.py @@ -46,6 +46,17 @@ def err(ctx, msg, code=1, data=None): click.echo(f"❌ Error: {msg}", err=True) sys.exit(code) +def _handle_unexpected_error(ctx, command_name, e): + from tdw_services.utils import log_error + log_error(f"Unexpected error in {command_name}: {e}") + err(ctx, f"An unexpected error occurred in {command_name}.") + +def _get_body_content(ctx, orch, file, body): + content = body if body is not None else (orch._read_safe_file(file) if file else None) + if content is None: + err(ctx, "Provide --file or --body") + return content + # ========================================== # REPO COMMAND GROUP # ========================================== @@ -170,18 +181,69 @@ def audit_pr(ctx, pr_number, fetch, run_audit, submit, cleanup, dry_run, base, e @gh.command() @click.option('--title', required=True, help='Issue title') -@click.option('--file', required=True, help='Path to file containing issue body') +@click.option('--file', help='Path to file containing issue body') +@click.option('--body', help='Literal body text') +@click.pass_context +def create_issue(ctx, title, file, body): + """Create a new GitHub issue.""" + orch = ctx.obj['ORCHESTRATOR'] + try: + content = _get_body_content(ctx, orch, file, body) + res = orch.create_issue(title, content) + out(ctx, f"✅ Successfully created issue: {res.get('html_url')}", data={"issue": res}) + except CLIError as e: + err(ctx, str(e), code=e.code) + except Exception as e: + _handle_unexpected_error(ctx, "create-issue", e) + +@gh.command() +@click.argument('issue_number', type=int) +@click.pass_context +def issue_view(ctx, issue_number): + """View details of a GitHub issue.""" + orch = ctx.obj['ORCHESTRATOR'] + try: + issue = orch.get_issue_details(issue_number) + msg = f"Issue #{issue.get('number')}: {issue.get('title')}\nState: {issue.get('state')}\n\n{issue.get('body')}" + out(ctx, msg, data={"issue": issue}) + except CLIError as e: + err(ctx, str(e), code=e.code) + except Exception as e: + _handle_unexpected_error(ctx, "issue-view", e) + +@gh.command() +@click.argument('issue_number', type=int) +@click.option('--file', help='Path to file containing new issue body') +@click.option('--body', help='Literal body text') +@click.pass_context +def issue_update(ctx, issue_number, file, body): + """Update a GitHub issue's body.""" + orch = ctx.obj['ORCHESTRATOR'] + try: + content = _get_body_content(ctx, orch, file, body) + res = orch.update_issue_body(issue_number, content) + out(ctx, f"✅ Successfully updated issue #{issue_number}", data={"issue": res}) + except CLIError as e: + err(ctx, str(e), code=e.code) + except Exception as e: + _handle_unexpected_error(ctx, "issue-update", e) + +@gh.command() +@click.argument('issue_number', type=int) +@click.option('--file', help='Path to file containing comment body') +@click.option('--body', help='Literal body text') @click.pass_context -def create_issue(ctx, title, file): - """Create a new GitHub issue from a file.""" +def issue_comment(ctx, issue_number, file, body): + """Post a comment to a GitHub issue.""" orch = ctx.obj['ORCHESTRATOR'] try: - res = orch.create_issue(title, file) - out(ctx, f"✅ Successfully created issue: {res.get('html_url')}", data=res) + content = _get_body_content(ctx, orch, file, body) + res = orch.post_comment(issue_number, content) + out(ctx, f"✅ Successfully posted comment to issue #{issue_number}", data={"comment": res}) except CLIError as e: err(ctx, str(e), code=e.code) except Exception as e: - err(ctx, str(e)) + _handle_unexpected_error(ctx, "issue-comment", e) @gh.command() @click.option('--issue-number', type=int) @@ -260,18 +322,20 @@ def detect_conflicts(ctx, pr): @gh.command() @click.option('--pr', required=True, type=int, help="The PR number to comment on.") -@click.option('--file', required=True, type=str, help="Path to the file containing the comment body.") +@click.option('--file', type=str, help="Path to the file containing the comment body.") +@click.option('--body', type=str, help="Literal comment text.") @click.pass_context -def post_comment(ctx, pr, file): - """Post a comment to a PR from a file.""" +def post_comment(ctx, pr, file, body): + """Post a comment to a PR.""" orch = ctx.obj['ORCHESTRATOR'] try: - res = orch.post_comment(pr, file) + content = _get_body_content(ctx, orch, file, body) + res = orch.post_comment(pr, content) out(ctx, f"✅ Successfully posted comment to PR #{pr}", data=res) except CLIError as e: err(ctx, str(e), code=e.code) except Exception as e: - err(ctx, str(e)) + _handle_unexpected_error(ctx, "post-comment", e) @gh.command() @click.pass_context diff --git a/dev-tools/tdw_services/orchestrator.py b/dev-tools/tdw_services/orchestrator.py index feb5d438f8..df10c51e22 100644 --- a/dev-tools/tdw_services/orchestrator.py +++ b/dev-tools/tdw_services/orchestrator.py @@ -291,23 +291,35 @@ def _read_safe_file(self, file_path: str, max_size: int = 1024 * 1024) -> str: with open(abs_path, 'r', encoding='utf-8') as f: return f.read() - def create_issue(self, title: str, file_path: str) -> Dict[str, Any]: + def create_issue(self, title: str, body: str) -> Dict[str, Any]: """ - Creates a new GitHub issue from a file, with validation. + Creates a new GitHub issue. """ - body = self._read_safe_file(file_path) - if not body.strip(): + if body is None or not body.strip(): raise CLIError("Issue body cannot be empty.") return self.github.create_issue(title, body) - def post_comment(self, pr_number: int, file_path: str) -> Dict[str, Any]: + def get_issue_details(self, issue_number: int) -> Dict[str, Any]: """ - Posts a comment to a PR from a file, with validation. + Fetches details of a GitHub issue. """ - body = self._read_safe_file(file_path) - if not body.strip(): + return self.github.fetch_issue_details(issue_number) + + def update_issue_body(self, issue_number: int, body: str) -> Dict[str, Any]: + """ + Updates an issue's body. + """ + if body is None or not body.strip(): + raise CLIError("Issue body cannot be empty.") + return self.github.update_issue(issue_number, body) + + def post_comment(self, entity_number: int, body: str) -> Dict[str, Any]: + """ + Posts a comment to a Pull Request or Issue. + """ + if not body or not body.strip(): raise CLIError("Comment body cannot be empty.") - return self.github.create_issue_comment(pr_number, body) + return self.github.create_issue_comment(entity_number, body) def validate_issue(self, issue_number: Optional[int] = None, all_open: bool = False, post_comments: bool = False, dry_run: bool = True) -> Dict[str, Any]: repo = get_github_client().get_repo(get_repo_name()) diff --git a/dev-tools/tdw_services/services/github.py b/dev-tools/tdw_services/services/github.py index ea5e4a8fc1..0e3fc5f3cc 100644 --- a/dev-tools/tdw_services/services/github.py +++ b/dev-tools/tdw_services/services/github.py @@ -130,6 +130,14 @@ def create_issue(self, title: str, body: str) -> Dict[str, Any]: """Creates a new GitHub issue.""" return self._request('POST', f'/repos/{self.repo}/issues', json_data={'title': title, 'body': body}) + def fetch_issue_details(self, number: int) -> Dict[str, Any]: + """Fetches the details of a GitHub issue.""" + return self._request('GET', f'/repos/{self.repo}/issues/{number}') + + def update_issue(self, number: int, body: str) -> Dict[str, Any]: + """Updates the body of a GitHub issue.""" + return self._request('PATCH', f'/repos/{self.repo}/issues/{number}', json_data={'body': body}) + def create_review(self, number: int, body: str, comments: List[Dict[str, Any]], event: str) -> Dict[str, Any]: data = { "body": body, diff --git a/tests/dev-tools/test_td_cli.py b/tests/dev-tools/test_td_cli.py index 07cc1307f0..719ca2dce2 100644 --- a/tests/dev-tools/test_td_cli.py +++ b/tests/dev-tools/test_td_cli.py @@ -161,29 +161,19 @@ def setUp(self): # Mock the github client on the orchestrator self.orch._github = self.mock_github - @patch('os.path.abspath') - @patch('os.getcwd') - @patch('os.path.exists') - @patch('os.path.getsize') - @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data='Issue Body') - def test_create_issue_success(self, mock_file, mock_getsize, mock_exists, mock_getcwd, mock_abspath): + def test_create_issue_success(self): """Test successful issue creation""" - mock_getcwd.return_value = '/app' - # Simple abspath mock for testing - mock_abspath.side_effect = lambda p: p if p.startswith('/') else os.path.join('/app', p) - mock_exists.return_value = True - mock_getsize.return_value = 100 self.mock_github.create_issue.return_value = {'html_url': 'http://github.com/issue/1'} - res = self.orch.create_issue('Title', 'body.md') + res = self.orch.create_issue('Title', 'Issue Body') self.assertEqual(res['html_url'], 'http://github.com/issue/1') self.mock_github.create_issue.assert_called_once_with('Title', 'Issue Body') @patch('os.path.abspath') @patch('os.getcwd') - def test_create_issue_path_traversal(self, mock_getcwd, mock_abspath): - """Test path traversal protection""" + def test_read_safe_file_path_traversal(self, mock_getcwd, mock_abspath): + """Test path traversal protection in _read_safe_file""" mock_getcwd.return_value = '/app' mock_abspath.side_effect = lambda p: p # Return path as is for simplicity @@ -191,15 +181,15 @@ def test_create_issue_path_traversal(self, mock_getcwd, mock_abspath): with patch('os.path.commonpath') as mock_common: mock_common.return_value = '/' # Root is NOT /app with self.assertRaises(td_cli.CLIError) as cm: - self.orch.create_issue('Title', '/etc/passwd') + self.orch._read_safe_file('/etc/passwd') self.assertIn("outside of repository root", cm.exception.message) @patch('os.path.abspath') @patch('os.getcwd') @patch('os.path.exists') @patch('os.path.getsize') - def test_create_issue_too_big(self, mock_getsize, mock_exists, mock_getcwd, mock_abspath): - """Test file size limit validation""" + def test_read_safe_file_too_big(self, mock_getsize, mock_exists, mock_getcwd, mock_abspath): + """Test file size limit validation in _read_safe_file""" # Ensure abspath returns a consistent root for validation mock_abspath.side_effect = lambda p: '/app/big.md' if 'big.md' in p else '/app' mock_exists.return_value = True @@ -208,7 +198,7 @@ def test_create_issue_too_big(self, mock_getsize, mock_exists, mock_getcwd, mock with patch('os.path.commonpath') as mock_common: mock_common.return_value = '/app' with self.assertRaises(td_cli.CLIError) as cm: - self.orch.create_issue('Title', 'big.md') + self.orch._read_safe_file('big.md') self.assertIn("exceeds limit of", cm.exception.message) if __name__ == '__main__':