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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@eldrforge/github-tools",
"version": "0.1.10",
"version": "0.1.12",
"description": "GitHub API utilities for automation - PR management, issue tracking, workflow monitoring",
"main": "dist/index.js",
"type": "module",
Expand Down
101 changes: 101 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,104 @@ export class PullRequestCheckError extends Error {
}
}

export class PullRequestCreationError extends Error {
public readonly statusCode: number;
public readonly existingPRNumber?: number;
public readonly existingPRUrl?: string;
public readonly head: string;
public readonly base: string;
public readonly details?: any;

constructor(
message: string,
statusCode: number,
head: string,
base: string,
details?: any,
existingPRNumber?: number,
existingPRUrl?: string
) {
super(message);
this.name = 'PullRequestCreationError';
this.statusCode = statusCode;
this.head = head;
this.base = base;
this.details = details;
this.existingPRNumber = existingPRNumber;
this.existingPRUrl = existingPRUrl;
}

getRecoveryInstructions(): string {
if (this.statusCode === 422) {
const errorMessage = this.details?.message || '';

// Check for specific 422 error patterns
if (errorMessage.includes('pull request already exists') ||
errorMessage.includes('A pull request already exists')) {
const instructions = [`❌ Failed to create PR: A pull request already exists for ${this.head} → ${this.base}`];

if (this.existingPRUrl) {
instructions.push('');
instructions.push(`📋 Existing PR: ${this.existingPRUrl}`);
}

instructions.push('');
instructions.push('Options:');
if (this.existingPRNumber) {
instructions.push(` 1. Reuse existing PR #${this.existingPRNumber} (command will detect and continue automatically)`);
instructions.push(` 2. Close existing PR: gh pr close ${this.existingPRNumber}`);
} else {
instructions.push(' 1. Check for existing PRs: gh pr list');
instructions.push(' 2. Close existing PR if needed: gh pr close <number>');
}
instructions.push(` 3. Use different branch name`);

return instructions.join('\n');
}

if (errorMessage.includes('No commits between') ||
errorMessage.includes('head and base')) {
return `❌ Failed to create PR: No commits between ${this.head} and ${this.base}

This usually means:
• The branches are already in sync
• No changes have been pushed to ${this.head}

What to do:
1. Verify you're on the correct branch: git branch
2. Check if there are unpushed commits: git log ${this.base}..${this.head}
3. If no changes exist, there's nothing to publish`;
}

if (errorMessage.includes('Validation Failed') ||
errorMessage.includes('field')) {
return `❌ Failed to create PR: Validation error

Error details: ${errorMessage}

Common causes:
• PR title too long (max 256 characters)
• Invalid characters in title or body
• Branch protection rules preventing PR creation

What to do:
1. Check your commit message is valid: git log -1
2. Verify branch protection settings in GitHub
3. Try creating the PR manually to see detailed error`;
}
}

// Generic recovery instructions
return `❌ Failed to create pull request (HTTP ${this.statusCode})

Error: ${this.details?.message || 'Unknown error'}

What to do:
1. Check GitHub API status: https://www.githubstatus.com/
2. Verify GITHUB_TOKEN permissions (needs 'repo' scope)
3. Check if base branch (${this.base}) exists
4. Verify working directory is clean: git status
5. Try viewing existing PRs: gh pr list --head ${this.head}`;
}
}

83 changes: 73 additions & 10 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,29 +95,92 @@ export const createPullRequest = async (
title: string,
body: string,
head: string,
base: string = 'main'
base: string = 'main',
options: { reuseExisting?: boolean } = {}
): Promise<PullRequest> => {
const octokit = getOctokit();
const { owner, repo } = await getRepoDetails();
const logger = getLogger();

// Check if PR already exists (pre-flight check)
if (options.reuseExisting !== false) {
logger.debug(`Checking for existing PR with head: ${head}`);
const existingPR = await findOpenPullRequestByHeadRef(head);

if (existingPR) {
if (existingPR.base.ref === base) {
logger.info(`♻️ Reusing existing PR #${existingPR.number}: ${existingPR.html_url}`);
return existingPR;
} else {
logger.warn(`⚠️ Existing PR #${existingPR.number} found but targets different base (${existingPR.base.ref} vs ${base})`);
logger.warn(` PR URL: ${existingPR.html_url}`);
logger.warn(` You may need to close the existing PR or use a different branch name`);
}
}
}

// Truncate title if it exceeds GitHub's limit
const truncatedTitle = truncatePullRequestTitle(title.trim());

if (truncatedTitle !== title.trim()) {
logger.debug(`Pull request title truncated from ${title.trim().length} to ${truncatedTitle.length} characters to meet GitHub's 256-character limit`);
}

const response = await octokit.pulls.create({
owner,
repo,
title: truncatedTitle,
body,
head,
base,
});
try {
const response = await octokit.pulls.create({
owner,
repo,
title: truncatedTitle,
body,
head,
base,
});

return response.data;
return response.data;
} catch (error: any) {
// Enhanced error handling for 422 errors
if (error.status === 422) {
const { PullRequestCreationError } = await import('./errors');

// Try to find existing PR to provide more helpful info
let existingPR: PullRequest | null = null;
try {
existingPR = await findOpenPullRequestByHeadRef(head);
} catch {
// Ignore errors finding existing PR
}

// If we found an existing PR that matches our target, reuse it instead of failing
if (existingPR && existingPR.base.ref === base) {
logger.info(`♻️ Found and reusing existing PR #${existingPR.number} (created after initial check)`);
logger.info(` URL: ${existingPR.html_url}`);
logger.info(` This can happen when PRs are created in parallel or from a previous failed run`);
return existingPR;
}

const prError = new PullRequestCreationError(
`Failed to create pull request: ${error.message}`,
422,
head,
base,
error.response?.data,
existingPR?.number,
existingPR?.html_url
);

// Log the detailed recovery instructions
const instructions = prError.getRecoveryInstructions();
for (const line of instructions.split('\n')) {
logger.error(line);
}
logger.error('');

throw prError;
}

// Re-throw other errors
throw error;
}
};

export const findOpenPullRequestByHeadRef = async (head: string): Promise<PullRequest | null> => {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type {
} from './types';

// Export errors
export { CommandError, ArgumentError } from './errors';
export { CommandError, ArgumentError, PullRequestCreationError, PullRequestCheckError } from './errors';

// Export logger configuration
export { setLogger, getLogger } from './logger';
Expand Down
149 changes: 149 additions & 0 deletions tests/createPullRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest';
import { PullRequestCreationError } from '../src/errors';

describe('PullRequestCreationError functionality', () => {
describe('error handling and recovery instructions', () => {
it('should provide recovery instructions for existing PR with PR number', () => {
const error = new PullRequestCreationError(
'Failed',
422,
'working',
'main',
{ message: 'A pull request already exists for user:working' },
123,
'https://github.com/owner/repo/pull/123'
);

const instructions = error.getRecoveryInstructions();

expect(instructions).toContain('pull request already exists');
expect(instructions).toContain('working → main');
expect(instructions).toContain('PR #123');
expect(instructions).toContain('https://github.com/owner/repo/pull/123');
expect(instructions).toContain('gh pr close 123');
expect(instructions).toContain('Reuse existing PR');
});

it('should provide recovery instructions without PR number', () => {
const error = new PullRequestCreationError(
'Failed',
422,
'working',
'main',
{ message: 'pull request already exists' }
);

const instructions = error.getRecoveryInstructions();

expect(instructions).toContain('gh pr list');
expect(instructions).toContain('Use different branch name');
});

it('should handle no commits between branches', () => {
const error = new PullRequestCreationError(
'Failed',
422,
'working',
'main',
{ message: 'No commits between working and main' }
);

const instructions = error.getRecoveryInstructions();

expect(instructions).toContain('No commits between');
expect(instructions).toContain('branches are already in sync');
expect(instructions).toContain('git log main..working');
});

it('should handle validation errors', () => {
const error = new PullRequestCreationError(
'Failed',
422,
'working',
'main',
{ message: 'Validation Failed: title is too long' }
);

const instructions = error.getRecoveryInstructions();

expect(instructions).toContain('Validation error');
expect(instructions).toContain('title is too long');
expect(instructions).toContain('PR title too long');
});

it('should provide generic recovery for non-422 errors', () => {
const error = new PullRequestCreationError(
'Failed',
401,
'working',
'main',
{ message: 'Bad credentials' }
);

const instructions = error.getRecoveryInstructions();

expect(instructions).toContain('HTTP 401');
expect(instructions).toContain('Bad credentials');
expect(instructions).toContain('GITHUB_TOKEN permissions');
});

it('should store all error properties correctly', () => {
const error = new PullRequestCreationError(
'Test message',
422,
'working',
'main',
{ message: 'Details' },
123,
'https://example.com'
);

expect(error.message).toBe('Test message');
expect(error.statusCode).toBe(422);
expect(error.head).toBe('working');
expect(error.base).toBe('main');
expect(error.details).toEqual({ message: 'Details' });
expect(error.existingPRNumber).toBe(123);
expect(error.existingPRUrl).toBe('https://example.com');
expect(error.name).toBe('PullRequestCreationError');
});
});

describe('PR reuse logic', () => {
it('should indicate when PR can be reused', () => {
const error = new PullRequestCreationError(
'Failed',
422,
'working',
'main',
{ message: 'A pull request already exists' },
123,
'https://github.com/owner/repo/pull/123'
);

const instructions = error.getRecoveryInstructions();

// Should suggest reusing existing PR
expect(instructions).toContain('Reuse existing PR');
expect(instructions).toContain('PR #123');
});

it('should provide alternative options when PR exists', () => {
const error = new PullRequestCreationError(
'Failed',
422,
'working',
'main',
{ message: 'A pull request already exists' },
123
);

const instructions = error.getRecoveryInstructions();

// Should provide multiple options
expect(instructions).toContain('Options:');
expect(instructions).toContain('Close existing PR');
expect(instructions).toContain('Use different branch');
});
});
});
Loading