feat: Add integrated release workflow with AI version suggestion
Adds a streamlined release workflow that uses AI (Claude Haiku) to analyze git commits and suggest semantic version bumps (major/minor/patch). Automatically updates package.json, commits, and pushes using a safe git workflow that preserves user's current work. New features: - RELEASE_SUGGEST_VERSION IPC for AI-powered version suggestions - bumpVersion() with safe git workflow (stash/checkout/commit/restore) - VersionSuggestion type with bumpType and reasoning - Updated ReleaseProgress to include bumping_version stage Also replaces VERSION file detection with requirements.txt across the codebase.
This commit is contained in:
@@ -39,8 +39,8 @@ function setupTestDirs(): void {
|
||||
// Create auto-claude source directory that getAutoBuildSourcePath looks for
|
||||
mkdirSync(AUTO_CLAUDE_SOURCE, { recursive: true });
|
||||
|
||||
// Create VERSION file (required by getAutoBuildSourcePath)
|
||||
writeFileSync(path.join(AUTO_CLAUDE_SOURCE, 'VERSION'), '1.0.0');
|
||||
// Create requirements.txt file (used as marker by getAutoBuildSourcePath)
|
||||
writeFileSync(path.join(AUTO_CLAUDE_SOURCE, 'requirements.txt'), '# Mock requirements');
|
||||
|
||||
// Create runners subdirectory (where spec_runner.py lives after restructure)
|
||||
mkdirSync(path.join(AUTO_CLAUDE_SOURCE, 'runners'), { recursive: true });
|
||||
|
||||
@@ -65,7 +65,8 @@ export class AgentProcessManager {
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
// Use requirements.txt as marker - it always exists in auto-claude source
|
||||
if (existsSync(p) && existsSync(path.join(p, 'requirements.txt'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -174,7 +175,8 @@ export class AgentProcessManager {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
...profileEnv, // Include active Claude profile config
|
||||
PYTHONUNBUFFERED: '1' // Ensure real-time output
|
||||
PYTHONUNBUFFERED: '1', // Ensure real-time output
|
||||
PYTHONIOENCODING: 'utf-8' // Ensure UTF-8 encoding on Windows
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -150,7 +150,8 @@ export class ChangelogService extends EventEmitter {
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
// Use requirements.txt as marker - it always exists in auto-claude source
|
||||
if (existsSync(p) && existsSync(path.join(p, 'requirements.txt'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ export class InsightsConfig {
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
// Use requirements.txt as marker - it always exists in auto-claude source
|
||||
if (existsSync(p) && existsSync(path.join(p, 'requirements.txt'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -103,7 +104,8 @@ export class InsightsConfig {
|
||||
...process.env as Record<string, string>,
|
||||
...autoBuildEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1'
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult } from '../../../shared/types';
|
||||
import type { IPCResult, GitCommit, VersionSuggestion } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { changelogService } from '../../changelog-service';
|
||||
import type { ReleaseOptions } from './types';
|
||||
|
||||
/**
|
||||
@@ -118,9 +121,146 @@ export function registerCreateRelease(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest git tag in the repository
|
||||
*/
|
||||
function getLatestTag(projectPath: string): string | null {
|
||||
try {
|
||||
const tag = execSync('git describe --tags --abbrev=0 2>/dev/null || echo ""', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
return tag || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commits since a specific tag (or all commits if no tag)
|
||||
*/
|
||||
function getCommitsSinceTag(projectPath: string, tag: string | null): GitCommit[] {
|
||||
try {
|
||||
const range = tag ? `${tag}..HEAD` : 'HEAD';
|
||||
const format = '%H|%s|%an|%ae|%aI';
|
||||
const output = execSync(`git log ${range} --pretty=format:"${format}"`, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
if (!output) return [];
|
||||
|
||||
return output.split('\n').map(line => {
|
||||
const [fullHash, subject, authorName, authorEmail, date] = line.split('|');
|
||||
return {
|
||||
hash: fullHash.substring(0, 7),
|
||||
fullHash,
|
||||
subject,
|
||||
author: authorName,
|
||||
authorEmail,
|
||||
date
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version from package.json
|
||||
*/
|
||||
function getCurrentVersion(projectPath: string): string {
|
||||
try {
|
||||
const pkgPath = path.join(projectPath, 'package.json');
|
||||
if (!existsSync(pkgPath)) {
|
||||
return '0.0.0';
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||||
return pkg.version || '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest version for release using AI analysis of commits
|
||||
*/
|
||||
export function registerSuggestVersion(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.RELEASE_SUGGEST_VERSION,
|
||||
async (_, projectId: string): Promise<IPCResult<VersionSuggestion>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current version from package.json
|
||||
const currentVersion = getCurrentVersion(project.path);
|
||||
|
||||
// Get latest tag
|
||||
const latestTag = getLatestTag(project.path);
|
||||
|
||||
// Get commits since last tag
|
||||
const commits = getCommitsSinceTag(project.path, latestTag);
|
||||
|
||||
if (commits.length === 0) {
|
||||
// No commits since last release, suggest patch bump
|
||||
const [major, minor, patch] = currentVersion.split('.').map(Number);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
suggestedVersion: `${major}.${minor}.${patch + 1}`,
|
||||
currentVersion,
|
||||
bumpType: 'patch',
|
||||
reason: 'No new commits since last release',
|
||||
commitCount: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Use AI to analyze commits and suggest version
|
||||
const suggestion = await changelogService.suggestVersionFromCommits(
|
||||
project.path,
|
||||
commits,
|
||||
currentVersion
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
suggestedVersion: suggestion.version,
|
||||
currentVersion,
|
||||
bumpType: suggestion.reason.includes('breaking') ? 'major' :
|
||||
suggestion.reason.includes('feature') || suggestion.reason.includes('minor') ? 'minor' : 'patch',
|
||||
reason: suggestion.reason,
|
||||
commitCount: commits.length
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Fallback to patch bump on error
|
||||
const currentVersion = getCurrentVersion(project.path);
|
||||
const [major, minor, patch] = currentVersion.split('.').map(Number);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
suggestedVersion: `${major}.${minor}.${patch + 1}`,
|
||||
currentVersion,
|
||||
bumpType: 'patch',
|
||||
reason: 'Fallback suggestion (AI analysis unavailable)',
|
||||
commitCount: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all release-related handlers
|
||||
*/
|
||||
export function registerReleaseHandlers(): void {
|
||||
registerCreateRelease();
|
||||
registerSuggestVersion();
|
||||
}
|
||||
|
||||
@@ -146,8 +146,9 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
}
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
const versionPath = path.join(p, 'VERSION');
|
||||
const exists = existsSync(p) && existsSync(versionPath);
|
||||
// Use requirements.txt as marker - it always exists in auto-claude source
|
||||
const markerPath = path.join(p, 'requirements.txt');
|
||||
const exists = existsSync(p) && existsSync(markerPath);
|
||||
|
||||
if (debug) {
|
||||
console.log(`[project-handlers:detectAutoBuildSourcePath] Checking ${p}: ${exists ? '✓ FOUND' : '✗ not found'}`);
|
||||
|
||||
@@ -60,8 +60,9 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
}
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
const versionPath = path.join(p, 'VERSION');
|
||||
const exists = existsSync(p) && existsSync(versionPath);
|
||||
// Use requirements.txt as marker - it always exists in auto-claude source
|
||||
const markerPath = path.join(p, 'requirements.txt');
|
||||
const exists = existsSync(p) && existsSync(markerPath);
|
||||
|
||||
if (debug) {
|
||||
console.log(`[detectAutoBuildSourcePath] Checking ${p}: ${exists ? '✓ FOUND' : '✗ not found'}`);
|
||||
|
||||
@@ -101,8 +101,9 @@ export interface InitializationResult {
|
||||
*/
|
||||
export function hasLocalSource(projectPath: string): boolean {
|
||||
const localSourcePath = path.join(projectPath, 'auto-claude');
|
||||
const versionFile = path.join(localSourcePath, 'VERSION');
|
||||
return existsSync(localSourcePath) && existsSync(versionFile);
|
||||
// Use requirements.txt as marker - it always exists in auto-claude source
|
||||
const markerFile = path.join(localSourcePath, 'requirements.txt');
|
||||
return existsSync(localSourcePath) && existsSync(markerFile);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, readdirSync } from 'fs';
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import type {
|
||||
ReleaseableVersion,
|
||||
@@ -17,7 +17,7 @@ import { DEFAULT_CHANGELOG_PATH } from '../shared/constants';
|
||||
|
||||
/**
|
||||
* Service for creating GitHub releases with worktree-aware pre-flight checks.
|
||||
*
|
||||
*
|
||||
* Key feature: Worktree checks are SCOPED to tasks in the release version.
|
||||
* If a worktree exists for a task NOT in this release, it won't block the release.
|
||||
*/
|
||||
@@ -32,7 +32,7 @@ export class ReleaseService extends EventEmitter {
|
||||
*/
|
||||
parseChangelogVersions(projectPath: string): ReleaseableVersion[] {
|
||||
const changelogPath = path.join(projectPath, DEFAULT_CHANGELOG_PATH);
|
||||
|
||||
|
||||
if (!existsSync(changelogPath)) {
|
||||
return [];
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export class ReleaseService extends EventEmitter {
|
||||
const version = match[1];
|
||||
const date = match[2] || '';
|
||||
const startIndex = match.index! + match[0].length;
|
||||
|
||||
|
||||
// Content is until next version header or end of file
|
||||
const endIndex = i < matches.length - 1 ? matches[i + 1].index! : content.length;
|
||||
const versionContent = content.slice(startIndex, endIndex).trim();
|
||||
@@ -98,17 +98,17 @@ export class ReleaseService extends EventEmitter {
|
||||
tasks: Task[]
|
||||
): Promise<ReleaseableVersion[]> {
|
||||
const versions = this.parseChangelogVersions(projectPath);
|
||||
|
||||
|
||||
// Populate task spec IDs for each version
|
||||
for (const version of versions) {
|
||||
const { specIds } = this.getTasksForVersion(projectPath, version.version, tasks);
|
||||
version.taskSpecIds = specIds;
|
||||
|
||||
|
||||
// Check if already released on GitHub
|
||||
try {
|
||||
const tagExists = this.checkTagExists(projectPath, version.tagName);
|
||||
version.isReleased = tagExists;
|
||||
|
||||
|
||||
if (tagExists) {
|
||||
// Try to get release URL
|
||||
version.releaseUrl = this.getGitHubReleaseUrl(projectPath, version.tagName);
|
||||
@@ -129,11 +129,11 @@ export class ReleaseService extends EventEmitter {
|
||||
try {
|
||||
// Check local tags
|
||||
execSync(`git tag -l "${tagName}"`, { cwd: projectPath, encoding: 'utf-8' });
|
||||
const localTags = execSync(`git tag -l "${tagName}"`, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
const localTags = execSync(`git tag -l "${tagName}"`, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
|
||||
if (localTags) return true;
|
||||
|
||||
// Check remote tags
|
||||
@@ -147,7 +147,7 @@ export class ReleaseService extends EventEmitter {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
|
||||
return !!remoteTags;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -166,7 +166,7 @@ export class ReleaseService extends EventEmitter {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
|
||||
return result || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -175,7 +175,7 @@ export class ReleaseService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Run pre-flight checks for a specific version.
|
||||
*
|
||||
*
|
||||
* IMPORTANT: Worktree checks are scoped to tasks in this version only.
|
||||
* Worktrees for other tasks (future releases) won't block this release.
|
||||
*/
|
||||
@@ -186,7 +186,7 @@ export class ReleaseService extends EventEmitter {
|
||||
): Promise<ReleasePreflightStatus> {
|
||||
const tagName = `v${version}`;
|
||||
const { specIds } = this.getTasksForVersion(projectPath, version, tasks);
|
||||
|
||||
|
||||
const status: ReleasePreflightStatus = {
|
||||
canRelease: false,
|
||||
checks: {
|
||||
@@ -303,7 +303,7 @@ export class ReleaseService extends EventEmitter {
|
||||
if (unmergedWorktrees.length === 0) {
|
||||
status.checks.worktreesMerged = {
|
||||
passed: true,
|
||||
message: specIds.length > 0
|
||||
message: specIds.length > 0
|
||||
? `All ${specIds.length} feature(s) in this release are merged`
|
||||
: 'No features to check (version may have been manually added)',
|
||||
unmergedWorktrees: []
|
||||
@@ -314,7 +314,7 @@ export class ReleaseService extends EventEmitter {
|
||||
message: `${unmergedWorktrees.length} feature(s) have unmerged worktrees`,
|
||||
unmergedWorktrees
|
||||
};
|
||||
|
||||
|
||||
for (const wt of unmergedWorktrees) {
|
||||
status.blockers.push(
|
||||
`Feature "${wt.taskTitle}" (${wt.specId}) has unmerged changes in worktree`
|
||||
@@ -330,7 +330,7 @@ export class ReleaseService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Check worktrees ONLY for tasks that are part of this release version.
|
||||
*
|
||||
*
|
||||
* This is the key function that scopes worktree checks to the release:
|
||||
* - If a task is in the release AND has an unmerged worktree → BLOCK
|
||||
* - If a task is NOT in the release but has a worktree → IGNORE (it's for a future release)
|
||||
@@ -344,7 +344,7 @@ export class ReleaseService extends EventEmitter {
|
||||
|
||||
// Get worktrees directory
|
||||
const worktreesDir = path.join(projectPath, '.worktrees', 'auto-claude');
|
||||
|
||||
|
||||
if (!existsSync(worktreesDir)) {
|
||||
// No worktrees exist at all - all clear
|
||||
return [];
|
||||
@@ -364,7 +364,7 @@ export class ReleaseService extends EventEmitter {
|
||||
for (const specId of releaseSpecIds) {
|
||||
// Find the worktree folder for this spec
|
||||
// Spec IDs are like "001-feature-name", worktree folders match
|
||||
const worktreeFolder = worktreeFolders.find(folder =>
|
||||
const worktreeFolder = worktreeFolders.find(folder =>
|
||||
folder === specId || folder.startsWith(`${specId}-`)
|
||||
);
|
||||
|
||||
@@ -374,7 +374,7 @@ export class ReleaseService extends EventEmitter {
|
||||
}
|
||||
|
||||
const worktreePath = path.join(worktreesDir, worktreeFolder);
|
||||
|
||||
|
||||
// Get the task info for better error messages
|
||||
const task = tasks.find(t => t.specId === specId);
|
||||
const taskTitle = task?.title || specId;
|
||||
@@ -442,7 +442,7 @@ export class ReleaseService extends EventEmitter {
|
||||
cwd: worktreePath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
|
||||
return !hasChanges;
|
||||
}
|
||||
|
||||
@@ -454,7 +454,167 @@ export class ReleaseService extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub release.
|
||||
* Bump version in package.json with safe git workflow.
|
||||
* Preserves user's current work by stashing, switching to main, then restoring.
|
||||
*/
|
||||
async bumpVersion(
|
||||
projectPath: string,
|
||||
version: string,
|
||||
mainBranch: string,
|
||||
projectId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Save current state
|
||||
let originalBranch: string;
|
||||
let hadChanges = false;
|
||||
let stashCreated = false;
|
||||
|
||||
try {
|
||||
originalBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
} catch {
|
||||
return { success: false, error: 'Failed to get current git branch' };
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
const gitStatus = execSync('git status --porcelain', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
hadChanges = !!gitStatus;
|
||||
|
||||
try {
|
||||
// Stash any changes (staged or unstaged)
|
||||
if (hadChanges) {
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'bumping_version',
|
||||
progress: 5,
|
||||
message: 'Stashing current changes...'
|
||||
});
|
||||
|
||||
execSync('git stash push -m "auto-claude-release-temp"', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
stashCreated = true;
|
||||
}
|
||||
|
||||
// Checkout main branch
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'bumping_version',
|
||||
progress: 10,
|
||||
message: `Switching to ${mainBranch}...`
|
||||
});
|
||||
|
||||
if (originalBranch !== mainBranch) {
|
||||
execSync(`git checkout "${mainBranch}"`, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
}
|
||||
|
||||
// Pull latest from origin
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'bumping_version',
|
||||
progress: 15,
|
||||
message: `Pulling latest from origin/${mainBranch}...`
|
||||
});
|
||||
|
||||
try {
|
||||
execSync(`git pull origin "${mainBranch}"`, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
} catch {
|
||||
// Pull might fail if no upstream, continue anyway
|
||||
}
|
||||
|
||||
// Update package.json
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'bumping_version',
|
||||
progress: 20,
|
||||
message: `Updating package.json to ${version}...`
|
||||
});
|
||||
|
||||
const pkgPath = path.join(projectPath, 'package.json');
|
||||
if (!existsSync(pkgPath)) {
|
||||
throw new Error('package.json not found in project root');
|
||||
}
|
||||
|
||||
const pkgContent = readFileSync(pkgPath, 'utf-8');
|
||||
const pkg = JSON.parse(pkgContent);
|
||||
pkg.version = version;
|
||||
|
||||
// Preserve formatting (detect indent)
|
||||
const indent = pkgContent.match(/^(\s+)/m)?.[1] || ' ';
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n');
|
||||
|
||||
// Stage and commit only package.json
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'bumping_version',
|
||||
progress: 25,
|
||||
message: 'Committing version bump...'
|
||||
});
|
||||
|
||||
execSync('git add package.json', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
execSync(`git commit -m "chore: release v${version}"`, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
// Push to origin
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'bumping_version',
|
||||
progress: 30,
|
||||
message: `Pushing to origin/${mainBranch}...`
|
||||
});
|
||||
|
||||
execSync(`git push origin "${mainBranch}"`, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
|
||||
} finally {
|
||||
// Always restore user's original state
|
||||
try {
|
||||
if (originalBranch !== mainBranch) {
|
||||
execSync(`git checkout "${originalBranch}"`, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Log but don't fail - user might need to manually switch back
|
||||
console.warn('[ReleaseService] Failed to restore original branch');
|
||||
}
|
||||
|
||||
if (stashCreated) {
|
||||
try {
|
||||
execSync('git stash pop', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
} catch {
|
||||
// Stash conflict - warn user
|
||||
console.warn('[ReleaseService] Failed to pop stash - user may need to run "git stash pop" manually');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub release with optional version bump.
|
||||
*/
|
||||
async createRelease(
|
||||
projectPath: string,
|
||||
@@ -462,12 +622,36 @@ export class ReleaseService extends EventEmitter {
|
||||
): Promise<CreateReleaseResult> {
|
||||
const tagName = `v${request.version}`;
|
||||
const title = request.title || tagName;
|
||||
const shouldBumpVersion = request.bumpVersion !== false; // Default to true
|
||||
|
||||
try {
|
||||
// Stage 0: Bump version in package.json (if enabled)
|
||||
if (shouldBumpVersion && request.mainBranch) {
|
||||
const bumpResult = await this.bumpVersion(
|
||||
projectPath,
|
||||
request.version,
|
||||
request.mainBranch,
|
||||
request.projectId
|
||||
);
|
||||
|
||||
if (!bumpResult.success) {
|
||||
this.emitProgress(request.projectId, {
|
||||
stage: 'error',
|
||||
progress: 0,
|
||||
message: `Version bump failed: ${bumpResult.error}`,
|
||||
error: bumpResult.error
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `Version bump failed: ${bumpResult.error}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1: Create local tag
|
||||
this.emitProgress(request.projectId, {
|
||||
stage: 'tagging',
|
||||
progress: 25,
|
||||
progress: 40,
|
||||
message: `Creating tag ${tagName}...`
|
||||
});
|
||||
|
||||
@@ -479,7 +663,7 @@ export class ReleaseService extends EventEmitter {
|
||||
// Stage 2: Push tag to remote
|
||||
this.emitProgress(request.projectId, {
|
||||
stage: 'pushing',
|
||||
progress: 50,
|
||||
progress: 60,
|
||||
message: `Pushing tag ${tagName} to origin...`
|
||||
});
|
||||
|
||||
@@ -491,7 +675,7 @@ export class ReleaseService extends EventEmitter {
|
||||
// Stage 3: Create GitHub release
|
||||
this.emitProgress(request.projectId, {
|
||||
stage: 'creating_release',
|
||||
progress: 75,
|
||||
progress: 80,
|
||||
message: 'Creating GitHub release...'
|
||||
});
|
||||
|
||||
@@ -567,7 +751,7 @@ export class ReleaseService extends EventEmitter {
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
|
||||
// Try to clean up the tag if it was created but release failed
|
||||
try {
|
||||
execSync(`git tag -d "${tagName}" 2>/dev/null || true`, {
|
||||
@@ -602,4 +786,3 @@ export class ReleaseService extends EventEmitter {
|
||||
|
||||
// Export singleton instance
|
||||
export const releaseService = new ReleaseService();
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ export class TerminalNameGenerator extends EventEmitter {
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
// Use requirements.txt as marker - it always exists in auto-claude source
|
||||
if (existsSync(p) && existsSync(path.join(p, 'requirements.txt'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -135,7 +136,8 @@ export class TerminalNameGenerator extends EventEmitter {
|
||||
...process.env,
|
||||
...autoBuildEnv,
|
||||
...profileEnv, // Include active Claude profile config
|
||||
PYTHONUNBUFFERED: '1'
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ export class TitleGenerator extends EventEmitter {
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
// Use requirements.txt as marker - it always exists in auto-claude source
|
||||
if (existsSync(p) && existsSync(path.join(p, 'requirements.txt'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -134,7 +135,8 @@ export class TitleGenerator extends EventEmitter {
|
||||
...process.env,
|
||||
...autoBuildEnv,
|
||||
...profileEnv, // Include active Claude profile config
|
||||
PYTHONUNBUFFERED: '1'
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
GitHubImportResult,
|
||||
GitHubInvestigationStatus,
|
||||
GitHubInvestigationResult,
|
||||
IPCResult
|
||||
IPCResult,
|
||||
VersionSuggestion
|
||||
} from '../../../shared/types';
|
||||
import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc-utils';
|
||||
|
||||
@@ -28,6 +29,9 @@ export interface GitHubAPI {
|
||||
options?: { draft?: boolean; prerelease?: boolean }
|
||||
) => Promise<IPCResult<{ url: string }>>;
|
||||
|
||||
/** AI-powered version suggestion based on commits since last release */
|
||||
suggestReleaseVersion: (projectId: string) => Promise<IPCResult<VersionSuggestion>>;
|
||||
|
||||
// OAuth operations (gh CLI)
|
||||
checkGitHubCli: () => Promise<IPCResult<{ installed: boolean; version?: string }>>;
|
||||
checkGitHubAuth: () => Promise<IPCResult<{ authenticated: boolean; username?: string }>>;
|
||||
@@ -79,6 +83,9 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
): Promise<IPCResult<{ url: string }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_CREATE_RELEASE, projectId, version, releaseNotes, options),
|
||||
|
||||
suggestReleaseVersion: (projectId: string): Promise<IPCResult<VersionSuggestion>> =>
|
||||
invokeIpc(IPC_CHANNELS.RELEASE_SUGGEST_VERSION, projectId),
|
||||
|
||||
// OAuth operations (gh CLI)
|
||||
checkGitHubCli: (): Promise<IPCResult<{ installed: boolean; version?: string }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_CHECK_CLI),
|
||||
|
||||
@@ -269,5 +269,14 @@ export const IPC_CHANNELS = {
|
||||
APP_UPDATE_AVAILABLE: 'app-update:available',
|
||||
APP_UPDATE_DOWNLOADED: 'app-update:downloaded',
|
||||
APP_UPDATE_PROGRESS: 'app-update:progress',
|
||||
APP_UPDATE_ERROR: 'app-update:error'
|
||||
APP_UPDATE_ERROR: 'app-update:error',
|
||||
|
||||
// Release operations
|
||||
RELEASE_SUGGEST_VERSION: 'release:suggestVersion',
|
||||
RELEASE_CREATE: 'release:create',
|
||||
RELEASE_PREFLIGHT: 'release:preflight',
|
||||
RELEASE_GET_VERSIONS: 'release:getVersions',
|
||||
|
||||
// Release events (main -> renderer)
|
||||
RELEASE_PROGRESS: 'release:progress'
|
||||
} as const;
|
||||
|
||||
@@ -183,6 +183,10 @@ export interface CreateReleaseRequest {
|
||||
body: string;
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
/** Main branch to push version bump to (uses project setting if not specified) */
|
||||
mainBranch?: string;
|
||||
/** Whether to bump version in package.json before release (default: true) */
|
||||
bumpVersion?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateReleaseResult {
|
||||
@@ -193,8 +197,19 @@ export interface CreateReleaseResult {
|
||||
}
|
||||
|
||||
export interface ReleaseProgress {
|
||||
stage: 'checking' | 'tagging' | 'pushing' | 'creating_release' | 'complete' | 'error';
|
||||
stage: 'bumping_version' | 'checking' | 'tagging' | 'pushing' | 'creating_release' | 'complete' | 'error';
|
||||
progress: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI-powered version suggestion result
|
||||
*/
|
||||
export interface VersionSuggestion {
|
||||
suggestedVersion: string;
|
||||
currentVersion: string;
|
||||
bumpType: 'major' | 'minor' | 'patch';
|
||||
reason: string;
|
||||
commitCount: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user