@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
252 lines • 11.7 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
/**
* Git PR Tool
*
* Pull request management using gh CLI: create, view, list.
*/
import { Box, Text } from 'ink';
import React from 'react';
import { useTheme } from '../../hooks/useTheme.js';
import { jsonSchema, tool } from '../../types/core.js';
import { execGh, getCommits, getCurrentBranch, getDefaultBranch, getUpstreamBranch, } from './utils.js';
// ============================================================================
// Preview
// ============================================================================
async function getCreatePreview(base) {
const branch = await getCurrentBranch();
const commits = await getCommits({ range: `${base}..HEAD` });
return { commits, branch };
}
// ============================================================================
// Execution
// ============================================================================
const executeGitPr = async (args) => {
try {
// CREATE
if (args.create) {
const base = args.create.base || (await getDefaultBranch());
const branch = await getCurrentBranch();
// Check if upstream is set
const upstream = await getUpstreamBranch();
if (!upstream) {
return 'Error: No upstream branch set. Push your branch first with git_push (setUpstream: true).';
}
// Build gh command
const ghArgs = [
'pr',
'create',
'--title',
args.create.title,
'--base',
base,
];
if (args.create.body) {
ghArgs.push('--body', args.create.body);
}
else {
ghArgs.push('--body', '');
}
if (args.create.draft) {
ghArgs.push('--draft');
}
const output = await execGh(ghArgs);
const lines = [];
lines.push('Pull request created successfully!');
lines.push('');
lines.push(`Title: ${args.create.title}`);
lines.push(`Base: ${base} ← ${branch}`);
// Extract PR URL from output
const urlMatch = output.match(/https:\/\/github\.com\/[^\s]+/);
if (urlMatch) {
lines.push('');
lines.push(`URL: ${urlMatch[0]}`);
}
if (args.create.draft) {
lines.push('');
lines.push('(Created as draft)');
}
return lines.join('\n');
}
// VIEW
if (args.view !== undefined) {
const output = await execGh([
'pr',
'view',
args.view.toString(),
'--json',
'number,title,state,author,url,body,headRefName,baseRefName,additions,deletions,changedFiles',
]);
const pr = JSON.parse(output);
const lines = [];
lines.push(`PR #${pr.number}: ${pr.title}`);
lines.push('');
lines.push(`State: ${pr.state}`);
lines.push(`Author: ${pr.author?.login || 'unknown'}`);
lines.push(`Branch: ${pr.baseRefName} ← ${pr.headRefName}`);
lines.push(`Changes: ${pr.changedFiles} files (+${pr.additions}, -${pr.deletions})`);
lines.push('');
lines.push(`URL: ${pr.url}`);
if (pr.body) {
lines.push('');
lines.push('Description:');
lines.push(pr.body.substring(0, 500));
if (pr.body.length > 500) {
lines.push('... (truncated)');
}
}
return lines.join('\n');
}
// LIST
if (args.list || (!args.create && args.view === undefined)) {
const state = args.list?.state || 'open';
const limit = args.list?.limit || 10;
const ghArgs = [
'pr',
'list',
'--state',
state,
'--limit',
limit.toString(),
'--json',
'number,title,state,author,headRefName,updatedAt',
];
if (args.list?.author) {
ghArgs.push('--author', args.list.author);
}
const output = await execGh(ghArgs);
const prs = JSON.parse(output);
if (prs.length === 0) {
return `No ${state} pull requests found.`;
}
const lines = [];
lines.push(`Pull requests (${state}, ${prs.length} found):`);
lines.push('');
for (const pr of prs) {
lines.push(`#${pr.number} ${pr.title}`);
lines.push(` ${pr.headRefName} by ${pr.author?.login || 'unknown'} (${pr.state})`);
}
return lines.join('\n');
}
return 'Error: No valid action specified. Use create, view, or list.';
}
catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
// Check for common gh errors
if (message.includes('gh auth login')) {
return 'Error: Not authenticated with GitHub. Run "gh auth login" first.';
}
if (message.includes('not a git repository')) {
return 'Error: Not in a git repository.';
}
if (message.includes('no upstream')) {
return 'Error: No upstream branch. Push your branch first.';
}
return `Error: ${message}`;
}
};
// ============================================================================
// Tool Definition
// ============================================================================
const gitPrCoreTool = tool({
description: 'Manage GitHub pull requests. Create new PR, view PR details, or list PRs. Requires gh CLI to be installed and authenticated.',
inputSchema: jsonSchema({
type: 'object',
properties: {
create: {
type: 'object',
description: 'Create a new pull request',
properties: {
title: {
type: 'string',
description: 'PR title (required)',
},
body: {
type: 'string',
description: 'PR description/body',
},
base: {
type: 'string',
description: 'Base branch (default: main/master)',
},
draft: {
type: 'boolean',
description: 'Create as draft PR',
},
},
required: ['title'],
},
view: {
type: 'number',
description: 'View details of a specific PR by number',
},
list: {
type: 'object',
description: 'List pull requests',
properties: {
state: {
type: 'string',
enum: ['open', 'closed', 'merged', 'all'],
description: 'Filter by state (default: open)',
},
author: {
type: 'string',
description: 'Filter by author (use "@me" for self)',
},
limit: {
type: 'number',
description: 'Max results (default: 10)',
},
},
},
},
required: [],
}),
// Approval varies by action
needsApproval: (args) => {
// ALWAYS_APPROVE for create (user should see title/body)
if (args.create) {
return true;
}
// AUTO for view and list
return false;
},
execute: async (args, _options) => {
return await executeGitPr(args);
},
});
// ============================================================================
// Formatter
// ============================================================================
function GitPrFormatter({ args, result, }) {
const { colors } = useTheme();
const [preview, setPreview] = React.useState(null);
// Determine action
const action = args.create
? 'create'
: args.view !== undefined
? 'view'
: 'list';
// Load preview for create before execution
React.useEffect(() => {
if (!result && args.create) {
(async () => {
const base = args.create?.base || (await getDefaultBranch());
const { commits, branch } = await getCreatePreview(base);
setPreview({ commits, branch, base });
})().catch(() => { });
}
}, [args, result]);
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: colors.tool, children: "\u2692 git_pr" }), _jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Action: " }), _jsx(Text, { color: colors.text, children: action })] }), action === 'create' && args.create && (_jsxs(_Fragment, { children: [preview && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Branch: " }), _jsx(Text, { color: colors.text, children: preview.branch })] })), preview && preview.commits.length > 0 && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Commits: " }), _jsx(Text, { color: colors.text, children: preview.commits.length })] })), args.create.draft && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "Draft: " }), _jsx(Text, { color: colors.warning, children: "yes" })] })), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: colors.secondary, children: "Title:" }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: colors.primary, children: args.create.title }) })] }), args.create.body && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: colors.secondary, children: "Body:" }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: _jsx(Text, { color: colors.text, children: args.create.body }) })] }))] })), action === 'view' && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "PR: " }), _jsxs(Text, { color: colors.primary, children: ["#", args.view] })] })), action === 'list' && (_jsxs(Box, { children: [_jsx(Text, { color: colors.secondary, children: "State: " }), _jsx(Text, { color: colors.text, children: args.list?.state || 'open' }), args.list?.author && (_jsxs(_Fragment, { children: [_jsx(Text, { color: colors.secondary, children: " by " }), _jsx(Text, { color: colors.text, children: args.list.author })] }))] })), result?.includes('created successfully') && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.success, children: "\u2713 PR created successfully" }) })), result?.includes('Error:') && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.error, children: ["\u2717 ", result] }) }))] }));
}
const formatter = (args, result) => {
return _jsx(GitPrFormatter, { args: args, result: result });
};
// ============================================================================
// Export
// ============================================================================
export const gitPrTool = {
name: 'git_pr',
tool: gitPrCoreTool,
formatter,
};
//# sourceMappingURL=git-pr.js.map