gitpt 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,20 @@
1
1
  # GitPT
2
2
 
3
- Git Prompt Tool is a CLI tool that helps you write commit messages using AI through [OpenRouter](https://openrouter.ai/).
3
+ Git Prompt Tool is a CLI tool that helps you write commit messages using AI through [OpenRouter](https://openrouter.ai/). It acts as a complete git wrapper, enhancing specific commands with AI while passing through all other git commands directly.
4
4
 
5
5
  ## Features
6
6
 
7
+ - Acts as a complete git replacement - all git commands are supported
7
8
  - Generate commit messages with AI based on your code changes
8
- - Compatible with all regular git commit options (you can treat it as an alias)
9
+
10
+ `gitpt commit`
11
+ - Create pull requests with AI-generated titles and descriptions
12
+
13
+ `gitpt pr create`
14
+ - Compatible with all regular git options (flags, arguments, etc.)
9
15
  - Edit suggested messages before committing
10
16
  - Works with various AI models via OpenRouter
17
+ - [Commitlint](https://commitlint.js.org/) support - read directly from your repository
11
18
 
12
19
  ## Installation
13
20
 
@@ -35,15 +42,29 @@ You'll need an [OpenRouter](https://openrouter.ai/) account to get an API key.
35
42
 
36
43
  ## Usage
37
44
 
38
- ### Adding Files
45
+ ### Using GitPT as a Complete Git Replacement
39
46
 
40
- Add files to the staging area using either git directly or the GitPT wrapper:
47
+ GitPT can be used as a direct replacement for git - any git command can be run through GitPT:
41
48
 
42
49
  ```bash
43
- # Standard git command
44
- git add .
50
+ # Standard git commands work exactly the same
51
+ gitpt status
52
+ gitpt log
53
+ gitpt branch
54
+ gitpt checkout -b new-feature
55
+ gitpt push origin main
56
+
57
+ # GitPT passes all arguments and options to git
58
+ gitpt log --oneline --graph
59
+ gitpt merge --no-ff feature-branch
60
+ ```
61
+
62
+ ### Adding Files
45
63
 
46
- # Or using GitPT wrapper
64
+ Add files to the staging area just like you would with git:
65
+
66
+ ```bash
67
+ # Same as git add . (supports all the regular git add options)
47
68
  gitpt add .
48
69
  ```
49
70
 
@@ -53,33 +74,86 @@ Generate an AI-powered commit message based on your staged changes:
53
74
 
54
75
  ```bash
55
76
  gitpt commit
77
+
78
+ # Or supply -m argument, if you want to avoid gitpt generating the message
79
+ gitpt commit -m "feat: file hash validation"
80
+
81
+ # Pass any other git commit options
82
+ gitpt commit --amend
56
83
  ```
57
84
 
58
85
  The tool will:
59
86
  1. Analyze your staged changes
60
87
  2. Generate a commit message using the configured AI model
61
- 3. Show you the suggested message
62
- 4. Let you edit the message before committing
63
- 5. Create the commit with your approved message
88
+ 3. Validate against commitlint rules (if configured)
89
+ 4. Regenerate the message if it fails validation
90
+ 5. Show you the suggested message
91
+ 6. Let you edit the message before committing
92
+ 7. Create the commit with your approved message
64
93
 
65
- ### Options
94
+ ### Changing Models
66
95
 
67
- You can use any standard git commit options with the `gitpt commit` command:
96
+ You can change the AI model at any time without re-entering your API key:
68
97
 
69
98
  ```bash
70
- # Skip editing the message
71
- gitpt commit --no-edit
99
+ # Select model interactively (fetches available models from OpenRouter)
100
+ gitpt model
72
101
 
73
- # Provide your own message instead of generating one
74
- gitpt commit -m "Your message here"
102
+ # Specify model directly
103
+ gitpt model openai/gpt-4o
75
104
 
76
- # Pass any other git commit options
77
- gitpt commit --amend
105
+ # Switch to a different Claude model
106
+ gitpt model anthropic/claude-3-haiku
107
+ ```
108
+
109
+ ## GitHub Usage
110
+
111
+ If you have GitHub CLI (`gh`) installed, you can use GitPT to interact with GitHub (e.g. generate full pull requests).
112
+
113
+ ### Creating Pull Requests
114
+
115
+ Generate AI-powered pull request titles and descriptions based on your changes:
116
+
117
+ ```bash
118
+ gitpt pr create
119
+ ```
120
+
121
+ The tool will:
122
+ 1. Analyze the commits and files changed since branching from the base branch
123
+ 2. Generate a suitable PR title and detailed description
124
+ 3. Show you the suggested content
125
+ 4. Let you edit the title and description before submission
126
+ 5. Create the pull request with your approved content
127
+
128
+ #### Pull Request Options
129
+
130
+ ```bash
131
+ # Create a draft PR
132
+ gitpt pr create --draft
133
+
134
+ # Specify a custom base branch
135
+ gitpt pr create --base develop
136
+
137
+ # Skip editing the PR details
138
+ gitpt pr create --no-edit
139
+
140
+ # Provide your own title instead of generating one
141
+ gitpt pr create --title "Your PR title here"
78
142
  ```
79
143
 
80
144
  ## How It Works
81
145
 
82
- GitPT sends a diff of your staged changes to the configured AI model via OpenRouter, which generates a contextual commit message following best practices.
146
+ GitPT leverages AI via OpenRouter to enhance your Git workflow while acting as a complete git wrapper:
147
+
148
+ - **Command Handling:** GitPT intelligently routes commands - enhanced commands (commit, pr) use AI capabilities while all other git commands are passed directly to git.
149
+
150
+ - **For commits:** Sends a diff of your staged changes to the AI, which generates a contextual commit message following best practices.
151
+
152
+ - **Commitlint Integration:** Automatically detects commitlint configuration files and validates generated commit messages against your project's commit conventions. If validation fails, it regenerates a compliant message.
153
+
154
+ - **For pull requests:** Analyzes the commits and file changes between your branch and the base branch, then generates a suitable title and detailed description for your PR.
155
+
156
+ - **For other git commands:** Passes them through directly to git with all arguments and options preserved, ensuring complete compatibility with your existing git workflow.
83
157
 
84
158
  ## Development
85
159
 
@@ -98,6 +172,20 @@ npm run build
98
172
  npm link
99
173
  ```
100
174
 
175
+ ## Commitlint Integration
176
+
177
+ GitPT automatically detects and integrates with [commitlint](https://commitlint.js.org/) if it's configured in your repository:
178
+
179
+ - **Automatic Detection:** GitPT checks for common commitlint configuration files (commitlint.config.js, .commitlintrc.*, etc.)
180
+
181
+ - **Rule-Aware Generation:** When commitlint is detected, GitPT instructs the AI to generate messages that follow your specific commit conventions
182
+
183
+ - **Validation & Regeneration:** Generated messages are validated against your commitlint rules before committing. If validation fails, GitPT automatically regenerates a compliant message
184
+
185
+ - **Error Feedback:** Validation errors are sent to the AI to help it understand how to fix the message
186
+
187
+ This integration ensures that all AI-generated commit messages follow your team's established commit conventions without requiring manual corrections.
188
+
101
189
  ## License
102
190
 
103
- MIT
191
+ MIT
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import { isGitRepository, hasStagedChanges, getStagedChanges, executeGitCommit } from '../utils/git.js';
4
4
  import { generateCommitMessage } from '../utils/api.js';
5
5
  import { getConfig } from '../utils/config.js';
6
+ import { hasCommitlintConfig, validateCommitMessage } from '../utils/commitlint.js';
6
7
  export async function commitCommand(options) {
7
8
  if (!isGitRepository()) {
8
9
  console.error(chalk.red('Error: Not a git repository'));
@@ -29,8 +30,32 @@ export async function commitCommand(options) {
29
30
  // Get staged changes
30
31
  const diff = getStagedChanges();
31
32
  console.log(chalk.blue('Generating commit message...'));
33
+ // Check if commitlint is configured
34
+ if (hasCommitlintConfig()) {
35
+ console.log(chalk.blue('Commitlint configuration detected. Generating message according to rules...'));
36
+ }
32
37
  // Generate commit message
33
38
  commitMessage = await generateCommitMessage(diff);
39
+ // If commitlint is configured, validate the message
40
+ if (hasCommitlintConfig()) {
41
+ console.log(chalk.blue('Validating commit message against commitlint rules...'));
42
+ const validation = await validateCommitMessage(commitMessage);
43
+ if (!validation.valid && validation.errors) {
44
+ console.log(chalk.yellow('Commit message failed validation. Regenerating...'));
45
+ console.log(chalk.gray(validation.errors));
46
+ // Regenerate with validation errors
47
+ commitMessage = await generateCommitMessage(diff, validation.errors);
48
+ // Validate again
49
+ const revalidation = await validateCommitMessage(commitMessage);
50
+ if (!revalidation.valid) {
51
+ console.log(chalk.yellow('Warning: Regenerated message still has validation issues.'));
52
+ console.log(chalk.gray(revalidation.errors));
53
+ }
54
+ }
55
+ else {
56
+ console.log(chalk.green('✓ Commit message passed validation'));
57
+ }
58
+ }
34
59
  console.log(chalk.green('✓ Commit message generated'));
35
60
  console.log('');
36
61
  console.log(chalk.cyan('Generated message:'));
@@ -0,0 +1 @@
1
+ export declare function modelCommand(modelId?: string): Promise<void>;
@@ -0,0 +1,114 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import fetch from 'node-fetch';
4
+ import { getConfig, saveConfig } from '../utils/config.js';
5
+ // List of popular models available on OpenRouter
6
+ const POPULAR_MODELS = [
7
+ { name: 'Claude 3 Opus - Anthropic', value: 'anthropic/claude-3-opus:beta' },
8
+ { name: 'Claude 3 Sonnet - Anthropic', value: 'anthropic/claude-3-sonnet:beta' },
9
+ { name: 'Claude 3 Haiku - Anthropic', value: 'anthropic/claude-3-haiku:beta' },
10
+ { name: 'GPT-4o - OpenAI', value: 'openai/gpt-4o' },
11
+ { name: 'GPT-4 Turbo - OpenAI', value: 'openai/gpt-4-turbo' },
12
+ { name: 'GPT-3.5 Turbo - OpenAI', value: 'openai/gpt-3.5-turbo' },
13
+ { name: 'Other (specify model identifier)', value: 'custom' }
14
+ ];
15
+ async function fetchAvailableModels(apiKey) {
16
+ try {
17
+ const response = await fetch('https://openrouter.ai/api/v1/models', {
18
+ headers: {
19
+ 'Authorization': `Bearer ${apiKey}`,
20
+ 'HTTP-Referer': 'https://github.com/bartaxyz/GitPT',
21
+ }
22
+ });
23
+ if (!response.ok) {
24
+ throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
25
+ }
26
+ const data = await response.json();
27
+ return data.data;
28
+ }
29
+ catch (error) {
30
+ console.error(chalk.red('Error fetching models:'), error);
31
+ return [];
32
+ }
33
+ }
34
+ export async function modelCommand(modelId) {
35
+ console.log(chalk.blue('GitPT Model Selection'));
36
+ // Check if config exists
37
+ const existingConfig = getConfig();
38
+ if (!existingConfig) {
39
+ console.error(chalk.red('GitPT is not configured. Please run "gitpt setup" first.'));
40
+ process.exit(1);
41
+ }
42
+ let selectedModel;
43
+ // If a model ID is provided directly, use it
44
+ if (modelId) {
45
+ selectedModel = modelId;
46
+ // Update config with the new model while keeping the existing API key
47
+ saveConfig({
48
+ apiKey: existingConfig.apiKey,
49
+ model: selectedModel
50
+ });
51
+ console.log(chalk.green(`✓ Model set to: ${chalk.yellow(selectedModel)}`));
52
+ return;
53
+ }
54
+ // Otherwise, show interactive selection
55
+ console.log('Current model:', chalk.yellow(existingConfig.model));
56
+ console.log('');
57
+ try {
58
+ // Try to fetch available models from OpenRouter
59
+ console.log(chalk.gray('Fetching available models from OpenRouter...'));
60
+ const availableModels = await fetchAvailableModels(existingConfig.apiKey);
61
+ let modelChoices;
62
+ if (availableModels.length > 0) {
63
+ // Convert available models to choices format
64
+ modelChoices = availableModels.map(model => ({
65
+ name: `${model.name} (Context: ${model.context_length})`,
66
+ value: model.id
67
+ }));
68
+ // Add custom option at the end
69
+ modelChoices.push({ name: 'Other (specify model identifier)', value: 'custom' });
70
+ console.log(chalk.green(`✓ Found ${availableModels.length} models available with your API key`));
71
+ }
72
+ else {
73
+ // Fallback to predefined list if API call fails
74
+ console.log(chalk.yellow('Could not fetch models from OpenRouter, using predefined list'));
75
+ modelChoices = POPULAR_MODELS;
76
+ }
77
+ const answers = await inquirer.prompt([
78
+ {
79
+ type: 'list',
80
+ name: 'modelChoice',
81
+ message: 'Select an AI model:',
82
+ choices: modelChoices,
83
+ default: () => {
84
+ // Try to find current model in the list to set as default
85
+ const currentIndex = modelChoices.findIndex(choice => choice.value === existingConfig.model);
86
+ return currentIndex >= 0 ? currentIndex : 0;
87
+ }
88
+ },
89
+ {
90
+ type: 'input',
91
+ name: 'customModel',
92
+ message: 'Enter model identifier:',
93
+ when: (answers) => answers.modelChoice === 'custom',
94
+ validate: (input) => {
95
+ if (!input)
96
+ return 'Model identifier is required';
97
+ return true;
98
+ }
99
+ }
100
+ ]);
101
+ // Get the selected model
102
+ selectedModel = answers.modelChoice === 'custom' ? answers.customModel : answers.modelChoice;
103
+ // Save the updated configuration
104
+ saveConfig({
105
+ apiKey: existingConfig.apiKey,
106
+ model: selectedModel
107
+ });
108
+ console.log(chalk.green(`✓ Model updated to: ${chalk.yellow(selectedModel)}`));
109
+ }
110
+ catch (error) {
111
+ console.error(chalk.red('Error updating model:'), error);
112
+ process.exit(1);
113
+ }
114
+ }
@@ -0,0 +1,10 @@
1
+ interface PullRequestOptions {
2
+ title?: string;
3
+ body?: string;
4
+ draft?: boolean;
5
+ base?: string;
6
+ edit?: boolean;
7
+ [key: string]: any;
8
+ }
9
+ export declare function prCreateCommand(options?: PullRequestOptions): Promise<void>;
10
+ export {};
@@ -0,0 +1,458 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import { execSync } from 'child_process';
4
+ import { isGitRepository } from '../utils/git.js';
5
+ import { getConfig } from '../utils/config.js';
6
+ import fetch from 'node-fetch';
7
+ function getCurrentBranch() {
8
+ try {
9
+ return execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
10
+ }
11
+ catch (error) {
12
+ console.error(chalk.red('Error getting current branch:'), error);
13
+ throw new Error('Failed to get current branch');
14
+ }
15
+ }
16
+ function getDefaultBaseBranch() {
17
+ try {
18
+ // First check for a default branch set in git config
19
+ try {
20
+ const defaultBranch = execSync('git config init.defaultBranch').toString().trim();
21
+ if (defaultBranch) {
22
+ return defaultBranch;
23
+ }
24
+ }
25
+ catch (error) {
26
+ // Continue if git config doesn't have default branch
27
+ }
28
+ // Next, check if GitHub CLI can tell us the default branch
29
+ try {
30
+ const repoInfo = execSync('gh repo view --json defaultBranchRef --jq .defaultBranchRef.name').toString().trim();
31
+ if (repoInfo) {
32
+ return repoInfo;
33
+ }
34
+ }
35
+ catch (error) {
36
+ // Continue if gh command fails
37
+ }
38
+ // Try to find default branch in remote branches list
39
+ const branches = execSync('git branch -r').toString().trim().split('\n');
40
+ // Common default branch names, in order of likelihood
41
+ const mainPatterns = [
42
+ /origin\/main$/,
43
+ /origin\/master$/,
44
+ /origin\/develop$/,
45
+ /origin\/dev$/,
46
+ /origin\/trunk$/
47
+ ];
48
+ // Try each pattern in order
49
+ for (const pattern of mainPatterns) {
50
+ const defaultBranch = branches.find(b => pattern.test(b.trim()));
51
+ if (defaultBranch) {
52
+ return defaultBranch.trim().replace(/^origin\//, '');
53
+ }
54
+ }
55
+ // Check for a branch that has 'HEAD -> origin/' in it, indicating the default branch
56
+ const headBranch = branches.find(b => b.includes('HEAD -> origin/'));
57
+ if (headBranch) {
58
+ const match = headBranch.match(/HEAD -> origin\/([^,\s]+)/);
59
+ if (match && match[1]) {
60
+ return match[1];
61
+ }
62
+ }
63
+ // Fallback to 'main' as most GitHub repos use this now
64
+ console.log(chalk.yellow('Could not determine default branch, using "main"'));
65
+ return 'main';
66
+ }
67
+ catch (error) {
68
+ // If we can't determine, default to main
69
+ console.log(chalk.yellow('Error detecting default branch, using "main"'));
70
+ return 'main';
71
+ }
72
+ }
73
+ function getCommitsSinceBaseBranch(baseBranch) {
74
+ try {
75
+ // Try first with origin/baseBranch
76
+ try {
77
+ const mergeBase = execSync(`git merge-base HEAD origin/${baseBranch}`).toString().trim();
78
+ const commitMessages = execSync(`git log --pretty=format:"%s" ${mergeBase}..HEAD`).toString().trim();
79
+ if (commitMessages) {
80
+ return commitMessages.split('\n').filter(Boolean);
81
+ }
82
+ }
83
+ catch (error) {
84
+ // If origin/baseBranch doesn't exist, try with just baseBranch
85
+ console.log(chalk.yellow(`No origin/${baseBranch} found, trying with local ${baseBranch} branch...`));
86
+ }
87
+ // Try with local branch
88
+ try {
89
+ const mergeBase = execSync(`git merge-base HEAD ${baseBranch}`).toString().trim();
90
+ const commitMessages = execSync(`git log --pretty=format:"%s" ${mergeBase}..HEAD`).toString().trim();
91
+ if (commitMessages) {
92
+ return commitMessages.split('\n').filter(Boolean);
93
+ }
94
+ }
95
+ catch (error) {
96
+ // If that fails too, fallback to simple branch comparison
97
+ console.log(chalk.yellow(`Merge base with ${baseBranch} not found, comparing branches directly...`));
98
+ }
99
+ // Direct branch comparison
100
+ try {
101
+ const commitMessages = execSync(`git log --pretty=format:"%s" ${baseBranch}..HEAD`).toString().trim();
102
+ if (commitMessages) {
103
+ return commitMessages.split('\n').filter(Boolean);
104
+ }
105
+ }
106
+ catch (error) {
107
+ console.log(chalk.yellow(`Could not compare with ${baseBranch}, using recent commits...`));
108
+ }
109
+ // Last resort: get most recent commits
110
+ const commitMessages = execSync('git log --pretty=format:"%s" -n 10').toString().trim();
111
+ return commitMessages.split('\n').filter(Boolean);
112
+ }
113
+ catch (error) {
114
+ console.error(chalk.yellow('Could not get commits. Using empty list.'));
115
+ return [];
116
+ }
117
+ }
118
+ function getChangedFiles(baseBranch) {
119
+ // Try several methods to get changed files
120
+ // Method 1: Compare with origin/baseBranch using three dots
121
+ try {
122
+ const changedFiles = execSync(`git diff --name-only origin/${baseBranch}...HEAD`).toString().trim();
123
+ if (changedFiles) {
124
+ return changedFiles.split('\n').filter(Boolean);
125
+ }
126
+ }
127
+ catch (error) {
128
+ // Continue to next method
129
+ }
130
+ // Method 2: Compare with local baseBranch using three dots
131
+ try {
132
+ const changedFiles = execSync(`git diff --name-only ${baseBranch}...HEAD`).toString().trim();
133
+ if (changedFiles) {
134
+ return changedFiles.split('\n').filter(Boolean);
135
+ }
136
+ }
137
+ catch (error) {
138
+ // Continue to next method
139
+ }
140
+ // Method 3: Direct comparison with two dots
141
+ try {
142
+ const changedFiles = execSync(`git diff --name-only ${baseBranch}..HEAD`).toString().trim();
143
+ if (changedFiles) {
144
+ return changedFiles.split('\n').filter(Boolean);
145
+ }
146
+ }
147
+ catch (error) {
148
+ // Continue to next method
149
+ }
150
+ // Method 4: Get recently modified files
151
+ try {
152
+ console.log(chalk.yellow(`Could not determine changed files relative to ${baseBranch}, using recently modified files...`));
153
+ const changedFiles = execSync('git ls-files --modified --others --exclude-standard').toString().trim();
154
+ if (changedFiles) {
155
+ return changedFiles.split('\n').filter(Boolean);
156
+ }
157
+ }
158
+ catch (error) {
159
+ // Last resort
160
+ }
161
+ // Method 5: List all files in the repo as a last resort
162
+ try {
163
+ console.log(chalk.yellow('Using all tracked files as fallback...'));
164
+ const allFiles = execSync('git ls-files').toString().trim();
165
+ return allFiles.split('\n').filter(Boolean).slice(0, 50); // Limit to first 50 files
166
+ }
167
+ catch (error) {
168
+ console.error(chalk.red('Could not determine changed files.'));
169
+ return [];
170
+ }
171
+ }
172
+ async function generatePRDetails(baseBranch, currentBranch) {
173
+ const config = getConfig();
174
+ if (!config) {
175
+ throw new Error('GitPT is not configured. Please run "gitpt setup" first.');
176
+ }
177
+ const { apiKey, model } = config;
178
+ // Get context for PR
179
+ const commitMessages = getCommitsSinceBaseBranch(baseBranch);
180
+ const changedFiles = getChangedFiles(baseBranch);
181
+ // Check if we have any content to work with
182
+ if (commitMessages.length === 0 && changedFiles.length === 0) {
183
+ console.log(chalk.yellow('No commits or changed files detected.'));
184
+ console.log(chalk.yellow('Will attempt to generate PR details using branch name and repository context.'));
185
+ }
186
+ // Get additional context from repository
187
+ let repoName = "";
188
+ let repoDescription = "";
189
+ try {
190
+ // Try to get repo information from GitHub CLI
191
+ const repoInfo = JSON.parse(execSync('gh repo view --json name,description').toString().trim());
192
+ repoName = repoInfo.name || "";
193
+ repoDescription = repoInfo.description || "";
194
+ }
195
+ catch (error) {
196
+ // Continue without this info
197
+ }
198
+ console.log(chalk.blue('Generating PR title and description...'));
199
+ // Build a rich context for the AI
200
+ let contextSections = [
201
+ `Branch: ${currentBranch}`,
202
+ `Base branch: ${baseBranch}`
203
+ ];
204
+ // Add repository info if available
205
+ if (repoName) {
206
+ contextSections.push(`Repository: ${repoName}`);
207
+ }
208
+ if (repoDescription) {
209
+ contextSections.push(`Repository description: ${repoDescription}`);
210
+ }
211
+ // Add commit messages if available
212
+ if (commitMessages.length > 0) {
213
+ contextSections.push('Commit messages in this branch:', commitMessages.map(msg => `- ${msg}`).join('\n'));
214
+ }
215
+ else {
216
+ contextSections.push('No commit messages available.');
217
+ // Try to extract intent from branch name if no commits
218
+ if (currentBranch.includes('/')) {
219
+ const branchParts = currentBranch.split('/');
220
+ const branchType = branchParts[0]; // e.g., "feature", "fix", "chore"
221
+ const branchDescription = branchParts.slice(1).join('/').replace(/-/g, ' ');
222
+ contextSections.push('Branch name analysis:', `Type: ${branchType}`, `Description: ${branchDescription}`);
223
+ }
224
+ }
225
+ // Add changed files if available
226
+ if (changedFiles.length > 0) {
227
+ contextSections.push('Files changed in this branch:', changedFiles.map(file => `- ${file}`).join('\n'));
228
+ }
229
+ else {
230
+ contextSections.push('No file changes detected.');
231
+ }
232
+ // Create the final context
233
+ const context = contextSections.join('\n\n');
234
+ const systemPrompt = `You are a helpful assistant that generates clear, informative GitHub pull request titles and descriptions.
235
+ For the title:
236
+ - Keep it concise (under 80 characters)
237
+ - Start with a verb in present tense (e.g., "Add", "Fix", "Update")
238
+ - Clearly summarize the main purpose of the changes
239
+
240
+ For the description:
241
+ - Start with a brief summary (1-2 sentences) of what the PR accomplishes
242
+ - Include a more detailed explanation of changes if needed
243
+ - List key changes as bullet points if there are multiple components
244
+ - Include any relevant context that reviewers should know
245
+ - End with any testing instructions if applicable
246
+
247
+ Format the description in Markdown with sections.
248
+ Do not include "PR" or "Pull Request" in the title.`;
249
+ const userPrompt = `Generate a pull request title and description for the following changes:
250
+
251
+ ${context}
252
+
253
+ Format your response exactly like this example:
254
+ Title: Add user authentication with JWT
255
+ Description:
256
+ ## Summary
257
+ This PR adds user authentication using JWT tokens.
258
+
259
+ ## Changes
260
+ - Implement login and registration endpoints
261
+ - Add JWT generation and validation
262
+ - Update user model with password hashing
263
+ - Add authorization middleware
264
+
265
+ ## How to test
266
+ 1. Register a new user with \`/api/register\`
267
+ 2. Login with the new user credentials
268
+ 3. Use the returned token to access protected endpoints`;
269
+ try {
270
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
271
+ method: 'POST',
272
+ headers: {
273
+ 'Content-Type': 'application/json',
274
+ 'Authorization': `Bearer ${apiKey}`,
275
+ 'HTTP-Referer': 'https://github.com/bartaxyz/GitPT',
276
+ },
277
+ body: JSON.stringify({
278
+ model: model,
279
+ messages: [
280
+ { role: 'system', content: systemPrompt },
281
+ { role: 'user', content: userPrompt }
282
+ ],
283
+ max_tokens: 1000,
284
+ }),
285
+ });
286
+ if (!response.ok) {
287
+ const errorText = await response.text();
288
+ throw new Error(`API request failed: ${response.status} ${response.statusText}\n${errorText}`);
289
+ }
290
+ const data = await response.json();
291
+ const result = data.choices[0].message.content.trim();
292
+ // Parse title and description from AI response
293
+ const titleMatch = result.match(/Title:\s*(.+?)(?:\n|$)/);
294
+ const descMatch = result.match(/Description:\s*\n([\s\S]+)$/);
295
+ const title = titleMatch ? titleMatch[1].trim() : '';
296
+ const body = descMatch ? descMatch[1].trim() : result; // Fallback to full response if parsing fails
297
+ return { title, body };
298
+ }
299
+ catch (error) {
300
+ console.error(chalk.red('Error generating PR details:'), error);
301
+ throw new Error('Failed to generate PR details');
302
+ }
303
+ }
304
+ function checkGitHubCLIAvailability() {
305
+ try {
306
+ execSync('gh --version', { stdio: 'ignore' });
307
+ return true;
308
+ }
309
+ catch (error) {
310
+ return false;
311
+ }
312
+ }
313
+ function createPullRequest(title, body, baseBranch, draft) {
314
+ const draftFlag = draft ? '--draft' : '';
315
+ try {
316
+ console.log(chalk.blue(`Creating pull request to ${baseBranch}...`));
317
+ // Create a temporary file for the PR body to avoid issues with escaping
318
+ const tempFilePath = `/tmp/gitpt-pr-body-${Date.now()}.md`;
319
+ try {
320
+ // Write the body to a temporary file
321
+ execSync(`cat > "${tempFilePath}" << 'GITPT_EOF'
322
+ ${body}
323
+ GITPT_EOF`);
324
+ // Try to get the remote repo URL if available
325
+ let repoUrlArg = '';
326
+ try {
327
+ const repoUrl = execSync('git config --get remote.origin.url').toString().trim();
328
+ if (repoUrl) {
329
+ repoUrlArg = `--repo "${repoUrl}"`;
330
+ }
331
+ }
332
+ catch (e) {
333
+ // Proceed without repo URL
334
+ }
335
+ // Use the file for the body
336
+ const command = `gh pr create --title "${title.replace(/"/g, '\\"')}" --body-file "${tempFilePath}" --base "${baseBranch}" ${draftFlag} ${repoUrlArg}`;
337
+ // Set a timeout to avoid hanging indefinitely
338
+ console.log(chalk.gray('Running GitHub PR creation command...'));
339
+ console.log(chalk.gray(`Using base branch: ${baseBranch}`));
340
+ // Add debugging output
341
+ console.log(chalk.gray('Executing command with 60s timeout:'));
342
+ // Execute the command with a timeout
343
+ const result = execSync(command, {
344
+ stdio: 'pipe',
345
+ timeout: 60000 // 60-second timeout
346
+ }).toString();
347
+ console.log(result);
348
+ console.log(chalk.green('✓ Pull request created successfully'));
349
+ }
350
+ finally {
351
+ // Clean up temporary file
352
+ try {
353
+ execSync(`rm -f "${tempFilePath}"`);
354
+ }
355
+ catch (e) {
356
+ // Ignore cleanup errors
357
+ }
358
+ }
359
+ }
360
+ catch (error) {
361
+ if (error instanceof Error && error.message.includes('timeout')) {
362
+ console.error(chalk.red('Error: GitHub CLI command timed out after 60 seconds.'));
363
+ console.log(chalk.yellow('You may need to create the PR manually using:'));
364
+ console.log(chalk.yellow(`gh pr create --title "${title}" --base "${baseBranch}" ${draftFlag}`));
365
+ }
366
+ else {
367
+ console.error(chalk.red('Error creating pull request:'), error);
368
+ }
369
+ throw new Error('Failed to create pull request');
370
+ }
371
+ }
372
+ export async function prCreateCommand(options = {}) {
373
+ if (!isGitRepository()) {
374
+ console.error(chalk.red('Error: Not a git repository'));
375
+ process.exit(1);
376
+ }
377
+ // Check if GitHub CLI is available
378
+ if (!checkGitHubCLIAvailability()) {
379
+ console.error(chalk.red('Error: GitHub CLI (gh) is not installed or not available in PATH.'));
380
+ console.log(chalk.yellow('Please install GitHub CLI from https://cli.github.com/'));
381
+ process.exit(1);
382
+ }
383
+ // Check if user is authenticated with GitHub CLI
384
+ try {
385
+ const authStatus = execSync('gh auth status -h github.com 2>&1 || true').toString();
386
+ if (authStatus.includes('not logged')) {
387
+ console.error(chalk.red('Error: You are not authenticated with GitHub CLI.'));
388
+ console.log(chalk.yellow('Please run `gh auth login` to authenticate.'));
389
+ process.exit(1);
390
+ }
391
+ }
392
+ catch (error) {
393
+ console.log(chalk.yellow('Warning: Could not verify GitHub CLI authentication.'));
394
+ console.log(chalk.yellow('If PR creation fails, please run `gh auth login` first.'));
395
+ }
396
+ // Get configuration
397
+ const config = getConfig();
398
+ if (!config) {
399
+ console.error(chalk.red('GitPT is not configured. Please run "gitpt setup" first.'));
400
+ process.exit(1);
401
+ }
402
+ try {
403
+ const currentBranch = getCurrentBranch();
404
+ const baseBranch = options.base || getDefaultBaseBranch();
405
+ let title = options.title || '';
406
+ let body = options.body || '';
407
+ // Generate PR details if not provided
408
+ if (!title || !body) {
409
+ const generatedDetails = await generatePRDetails(baseBranch, currentBranch);
410
+ title = title || generatedDetails.title;
411
+ body = body || generatedDetails.body;
412
+ console.log(chalk.green('✓ PR details generated'));
413
+ console.log('');
414
+ console.log(chalk.cyan('Generated title:'));
415
+ console.log(title);
416
+ console.log('');
417
+ console.log(chalk.cyan('Generated description:'));
418
+ console.log(body);
419
+ console.log('');
420
+ }
421
+ // Allow editing PR details
422
+ if (options.edit !== false) {
423
+ const answers = await inquirer.prompt([
424
+ {
425
+ type: 'input',
426
+ name: 'title',
427
+ message: 'Edit PR title:',
428
+ default: title
429
+ },
430
+ {
431
+ type: 'editor',
432
+ name: 'body',
433
+ message: 'Edit PR description:',
434
+ default: body
435
+ },
436
+ {
437
+ type: 'confirm',
438
+ name: 'draft',
439
+ message: 'Create as draft PR?',
440
+ default: options.draft || false
441
+ }
442
+ ]);
443
+ title = answers.title;
444
+ body = answers.body;
445
+ const isDraft = answers.draft;
446
+ // Create the PR
447
+ createPullRequest(title, body, baseBranch, isDraft);
448
+ }
449
+ else {
450
+ // Create the PR without editing
451
+ createPullRequest(title, body, baseBranch, options.draft || false);
452
+ }
453
+ }
454
+ catch (error) {
455
+ console.error(chalk.red('Error:'), error instanceof Error ? error.message : String(error));
456
+ process.exit(1);
457
+ }
458
+ }
package/dist/index.js CHANGED
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
+ import { execSync } from 'child_process';
4
5
  import { setupCommand } from './commands/setup.js';
5
6
  import { commitCommand } from './commands/commit.js';
6
7
  import { addCommand } from './commands/add.js';
8
+ import { modelCommand } from './commands/model.js';
9
+ import { prCreateCommand } from './commands/pr.js';
7
10
  import fs from 'fs';
8
11
  import path from 'path';
9
12
  import { fileURLToPath } from 'url';
13
+ import { isGitRepository } from './utils/git.js';
10
14
  const __filename = fileURLToPath(import.meta.url);
11
15
  const __dirname = path.dirname(__filename);
12
16
  const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'));
@@ -16,17 +20,21 @@ program
16
20
  .name('gitpt')
17
21
  .description('Git Prompt Tool helps you write commit messages using AI')
18
22
  .version(version);
19
- // Setup command
23
+ // GitPT-specific commands
20
24
  program
21
25
  .command('setup')
22
26
  .description('Configure GitPT with your OpenRouter API key and model selection')
23
27
  .action(setupCommand);
24
- // Add command (pass-through to git add)
28
+ program
29
+ .command('model [model-id]')
30
+ .description('Change the AI model used for generating commit messages')
31
+ .action(modelCommand);
32
+ // Enhanced git commands
25
33
  program
26
34
  .command('add [files...]')
27
35
  .description('Add files to git staging area (pass-through to git add)')
36
+ .allowUnknownOption(true) // Pass through other git add options
28
37
  .action(addCommand);
29
- // Commit command
30
38
  program
31
39
  .command('commit')
32
40
  .description('Generate AI-powered commit message based on staged changes')
@@ -35,6 +43,34 @@ program
35
43
  .option('--no-edit', 'do not edit the message after generation')
36
44
  .allowUnknownOption(true) // Pass through other git commit options
37
45
  .action(commitCommand);
46
+ program
47
+ .command('pr create')
48
+ .description('Create a pull request with AI-generated title and description')
49
+ .option('-t, --title <title>', 'Custom pull request title')
50
+ .option('-b, --body <body>', 'Custom pull request description')
51
+ .option('-d, --draft', 'Create as draft pull request')
52
+ .option('-B, --base <branch>', 'Base branch to create PR against')
53
+ .option('-e, --edit', 'Edit PR details before submission', true)
54
+ .option('--no-edit', 'Skip editing PR details')
55
+ .allowUnknownOption(true)
56
+ .action(prCreateCommand);
57
+ // Handle unknown commands by passing them to git
58
+ program.on('command:*', (operands) => {
59
+ if (!isGitRepository()) {
60
+ console.error(chalk.red('Error: Not a git repository'));
61
+ process.exit(1);
62
+ }
63
+ try {
64
+ // Get all arguments passed to the original command
65
+ const args = process.argv.slice(2);
66
+ // Execute git with all arguments
67
+ execSync(`git ${args.join(' ')}`, { stdio: 'inherit' });
68
+ }
69
+ catch (error) {
70
+ // Git will handle its own error output through stdio: 'inherit'
71
+ process.exit(1);
72
+ }
73
+ });
38
74
  // Main logic
39
75
  async function main() {
40
76
  try {
@@ -1 +1 @@
1
- export declare function generateCommitMessage(diff: string): Promise<string>;
1
+ export declare function generateCommitMessage(diff: string, validationErrors?: string): Promise<string>;
package/dist/utils/api.js CHANGED
@@ -1,7 +1,10 @@
1
- import fetch from 'node-fetch';
2
- import { getConfig } from './config.js';
3
- const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
4
- export async function generateCommitMessage(diff) {
1
+ import fetch from "node-fetch";
2
+ import { getConfig } from "./config.js";
3
+ import { hasCommitlintConfig, getCommitlintRules } from "./commitlint.js";
4
+ const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
5
+ export async function generateCommitMessage(diff, validationErrors) {
6
+ // Check if commitlint is configured
7
+ const hasCommitlint = hasCommitlintConfig();
5
8
  const config = getConfig();
6
9
  if (!config) {
7
10
  throw new Error('GitPT is not configured. Please run "gitpt setup" first.');
@@ -9,31 +12,34 @@ export async function generateCommitMessage(diff) {
9
12
  const { apiKey, model } = config;
10
13
  const messages = [
11
14
  {
12
- role: 'system',
13
- content: `You are a helpful assistant that generates concise, informative Git commit messages.
14
- Follow conventional commit style. Be brief but descriptive.
15
- Focus on WHAT changes were made, WHY they were made, and their IMPACT.
16
- Use present tense (e.g., "add feature" not "added feature").
17
- Split the message like this if needed:
18
-
19
- feat(scope): short description
20
-
21
- More detailed explanation if necessary
22
-
23
- Only include a detailed explanation for complex changes.`
15
+ role: "system",
16
+ content: `You are a helpful assistant that generates concise, informative Git commit messages.
17
+ Follow these strict rules:
18
+ ${hasCommitlint ? getCommitlintRules() : `1. Use conventional commit format: type: description
19
+ 2. Types are: feat, fix, docs, style, refactor, test, chore
20
+ 3. NO scopes in parentheses - do not use feat(scope)
21
+ 4. Keep the entire message under 100 characters
22
+ 5. Use present tense (e.g., "add feature" not "added feature")
23
+ 6. Be brief but descriptive about WHAT changed
24
+ 7. Do not include detailed explanations
25
+ 8. Examples:
26
+ - feat: add user authentication
27
+ - fix: resolve null pointer in login
28
+ - chore: update dependencies
29
+ - style: format css files`}${validationErrors ? `\n\nYOUR PREVIOUS MESSAGE FAILED VALIDATION WITH THESE ERRORS:\n${validationErrors}\n\nFIX THESE ISSUES IN YOUR NEW MESSAGE.` : ''}`,
24
30
  },
25
31
  {
26
- role: 'user',
27
- content: `Generate a commit message for the following git diff:\n\n${diff}`
28
- }
32
+ role: "user",
33
+ content: `Generate a commit message for the following git diff:\n\n${diff}`,
34
+ },
29
35
  ];
30
36
  try {
31
37
  const response = await fetch(OPENROUTER_API_URL, {
32
- method: 'POST',
38
+ method: "POST",
33
39
  headers: {
34
- 'Content-Type': 'application/json',
35
- 'Authorization': `Bearer ${apiKey}`,
36
- 'HTTP-Referer': 'https://github.com/bartaxyz/GitPT',
40
+ "Content-Type": "application/json",
41
+ Authorization: `Bearer ${apiKey}`,
42
+ "HTTP-Referer": "https://github.com/bartaxyz/GitPT",
37
43
  },
38
44
  body: JSON.stringify({
39
45
  model: model,
@@ -45,11 +51,11 @@ export async function generateCommitMessage(diff) {
45
51
  const errorText = await response.text();
46
52
  throw new Error(`API request failed: ${response.status} ${response.statusText}\n${errorText}`);
47
53
  }
48
- const data = await response.json();
54
+ const data = (await response.json());
49
55
  return data.choices[0].message.content.trim();
50
56
  }
51
57
  catch (error) {
52
- console.error('Error generating commit message:', error);
53
- throw new Error('Failed to generate commit message');
58
+ console.error("Error generating commit message:", error);
59
+ throw new Error("Failed to generate commit message");
54
60
  }
55
61
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Represents a parsed commitlint configuration
3
+ */
4
+ export interface CommitlintConfig {
5
+ rules?: Record<string, any>;
6
+ extends?: string | string[];
7
+ }
8
+ /**
9
+ * Check if a commitlint configuration exists in the repository
10
+ */
11
+ export declare function hasCommitlintConfig(): boolean;
12
+ /**
13
+ * Get the commitlint configuration format rules as a string
14
+ */
15
+ export declare function getCommitlintRules(): string;
16
+ /**
17
+ * Validate a commit message against commitlint rules
18
+ * @param message The commit message to validate
19
+ * @returns An object with success status and error message if applicable
20
+ */
21
+ export declare function validateCommitMessage(message: string): Promise<{
22
+ valid: boolean;
23
+ errors?: string;
24
+ }>;
@@ -0,0 +1,124 @@
1
+ import fs from 'fs';
2
+ import { execSync } from 'child_process';
3
+ /**
4
+ * Check if a commitlint configuration exists in the repository
5
+ */
6
+ export function hasCommitlintConfig() {
7
+ const possibleConfigFiles = [
8
+ '.commitlintrc',
9
+ '.commitlintrc.json',
10
+ '.commitlintrc.yaml',
11
+ '.commitlintrc.yml',
12
+ '.commitlintrc.js',
13
+ 'commitlint.config.js',
14
+ 'package.json'
15
+ ];
16
+ // Check if any of the possible config files exist
17
+ return possibleConfigFiles.some(file => {
18
+ try {
19
+ if (file === 'package.json') {
20
+ const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
21
+ return packageJson.commitlint !== undefined;
22
+ }
23
+ return fs.existsSync(file);
24
+ }
25
+ catch (error) {
26
+ return false;
27
+ }
28
+ });
29
+ }
30
+ /**
31
+ * Get the commitlint configuration format rules as a string
32
+ */
33
+ export function getCommitlintRules() {
34
+ try {
35
+ // Try to get the rules using commitlint CLI if available
36
+ try {
37
+ const rulesOutput = execSync('npx commitlint --print-config', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
38
+ const config = JSON.parse(rulesOutput);
39
+ return formatCommitlintRules(config);
40
+ }
41
+ catch (error) {
42
+ // If CLI approach fails, try to find and parse config files manually
43
+ // This is a simplified approach - in a real implementation, you'd want to handle
44
+ // all possible config files and formats
45
+ if (fs.existsSync('commitlint.config.js')) {
46
+ // We can't directly require the JS file in ESM, so we'll return a generic message
47
+ return "Follow the conventional commit format (type(scope): message)";
48
+ }
49
+ if (fs.existsSync('.commitlintrc.json')) {
50
+ const config = JSON.parse(fs.readFileSync('.commitlintrc.json', 'utf8'));
51
+ return formatCommitlintRules(config);
52
+ }
53
+ // Default to conventional commits format if we can't parse the config
54
+ return "Follow the conventional commit format (type(scope): message)";
55
+ }
56
+ }
57
+ catch (error) {
58
+ // If all else fails, return a generic message
59
+ return "Follow the conventional commit format (type(scope): message)";
60
+ }
61
+ }
62
+ /**
63
+ * Format commitlint rules into a human-readable string
64
+ */
65
+ function formatCommitlintRules(config) {
66
+ let rulesDescription = "Follow these commit message rules:\n";
67
+ if (!config.rules) {
68
+ return "Follow the conventional commit format (type(scope): message)";
69
+ }
70
+ // Extract type-enum rule if it exists
71
+ if (config.rules['type-enum'] && Array.isArray(config.rules['type-enum'][2])) {
72
+ const allowedTypes = config.rules['type-enum'][2];
73
+ rulesDescription += `- Commit type must be one of: ${allowedTypes.join(', ')}\n`;
74
+ }
75
+ // Extract other common rules
76
+ if (config.rules['scope-enum'] && Array.isArray(config.rules['scope-enum'][2])) {
77
+ const allowedScopes = config.rules['scope-enum'][2];
78
+ rulesDescription += `- Scope must be one of: ${allowedScopes.join(', ')}\n`;
79
+ }
80
+ if (config.rules['subject-case']) {
81
+ rulesDescription += `- Subject must follow case rules\n`;
82
+ }
83
+ if (config.rules['subject-max-length']) {
84
+ const maxLength = config.rules['subject-max-length'][2];
85
+ rulesDescription += `- Subject must be no longer than ${maxLength} characters\n`;
86
+ }
87
+ return rulesDescription;
88
+ }
89
+ /**
90
+ * Validate a commit message against commitlint rules
91
+ * @param message The commit message to validate
92
+ * @returns An object with success status and error message if applicable
93
+ */
94
+ export async function validateCommitMessage(message) {
95
+ if (!hasCommitlintConfig()) {
96
+ // If no commitlint config, consider it valid
97
+ return { valid: true };
98
+ }
99
+ try {
100
+ // Create a temporary file with the message
101
+ const tempFile = `/tmp/gitpt-commit-msg-${Date.now()}`;
102
+ fs.writeFileSync(tempFile, message);
103
+ try {
104
+ // Run commitlint against the file
105
+ execSync(`npx commitlint --config commitlint.config.js < ${tempFile}`, { stdio: 'ignore' });
106
+ // If we get here, validation passed
107
+ fs.unlinkSync(tempFile); // Clean up temp file
108
+ return { valid: true };
109
+ }
110
+ catch (error) {
111
+ // Capture the error output for feedback
112
+ const errorOutput = execSync(`npx commitlint --config commitlint.config.js < ${tempFile} 2>&1 || true`).toString();
113
+ fs.unlinkSync(tempFile); // Clean up temp file
114
+ return {
115
+ valid: false,
116
+ errors: errorOutput
117
+ };
118
+ }
119
+ }
120
+ catch (error) {
121
+ // If we can't run commitlint, consider it valid to avoid blocking
122
+ return { valid: true };
123
+ }
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitpt",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI tool that helps you write commit messages & pull request descriptions using AI",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -48,5 +48,9 @@
48
48
  "configstore": "^7.0.0",
49
49
  "inquirer": "^12.5.2",
50
50
  "node-fetch": "^3.3.2"
51
+ },
52
+ "optionalDependencies": {
53
+ "@commitlint/cli": "^18.4.3",
54
+ "@commitlint/config-conventional": "^18.4.3"
51
55
  }
52
56
  }