e-pick 2.0.0 → 3.0.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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Error Handling Middleware
3
+ *
4
+ * Global error handler for Express
5
+ */
6
+
7
+ import { serializeError } from '../utils/error-handler.js';
8
+
9
+ /**
10
+ * Global error handling middleware
11
+ * Must be registered last in middleware chain
12
+ */
13
+ export function errorHandler(err, req, res, next) {
14
+ console.error('Error:', err);
15
+
16
+ const serialized = serializeError(err);
17
+
18
+ res.status(serialized.statusCode || 500).json({
19
+ success: false,
20
+ ...serialized,
21
+ });
22
+ }
23
+
24
+ /**
25
+ * 404 Not Found handler
26
+ */
27
+ export function notFoundHandler(req, res) {
28
+ res.status(404).json({
29
+ success: false,
30
+ error: 'NotFound',
31
+ message: `Cannot ${req.method} ${req.path}`,
32
+ });
33
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Validation Middleware
3
+ *
4
+ * Request validation using Joi schemas
5
+ */
6
+
7
+ import Joi from 'joi';
8
+ import { ValidationError } from '../utils/error-handler.js';
9
+
10
+ /**
11
+ * Validate request body against Joi schema
12
+ * @param {object} schema - Joi schema
13
+ * @returns {Function} Express middleware
14
+ */
15
+ export function validateBody(schema) {
16
+ return (req, res, next) => {
17
+ const { error, value } = schema.validate(req.body, {
18
+ abortEarly: false,
19
+ stripUnknown: true,
20
+ });
21
+
22
+ if (error) {
23
+ const message = error.details.map((d) => d.message).join(', ');
24
+ return next(new ValidationError(message));
25
+ }
26
+
27
+ req.body = value;
28
+ next();
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Validate commits array schema
34
+ */
35
+ export const validateCommitsSchema = Joi.object({
36
+ commits: Joi.array()
37
+ .items(Joi.string().pattern(/^[0-9a-f]{40}$/i))
38
+ .min(1)
39
+ .required()
40
+ .messages({
41
+ 'array.min': 'At least one commit is required',
42
+ 'string.pattern.base': 'Each commit must be a valid 40-character hash',
43
+ }),
44
+ });
45
+
46
+ /**
47
+ * Generate command schema
48
+ */
49
+ export const generateCommandSchema = Joi.object({
50
+ commits: Joi.array()
51
+ .items(Joi.string().pattern(/^[0-9a-f]{40}$/i))
52
+ .min(1)
53
+ .required(),
54
+ ignoredCommits: Joi.array()
55
+ .items(Joi.string().pattern(/^[0-9a-f]{40}$/i))
56
+ .default([]),
57
+ options: Joi.object({
58
+ allowEmpty: Joi.boolean().default(false),
59
+ continueOnConflict: Joi.boolean().default(false),
60
+ }).default({}),
61
+ });
package/src/server.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Express Server
3
+ *
4
+ * Main server file that sets up Express app and routes.
5
+ */
6
+
7
+ import express from 'express';
8
+ import helmet from 'helmet';
9
+ import cors from 'cors';
10
+ import { fileURLToPath } from 'url';
11
+ import { dirname, join } from 'path';
12
+ import { readFileSync } from 'fs';
13
+ import appConfig from './config/app.config.js';
14
+ import * as commitController from './controllers/commit.controller.js';
15
+ import { validateBody, validateCommitsSchema, generateCommandSchema } from './middleware/validation.middleware.js';
16
+ import { errorHandler, notFoundHandler } from './middleware/error.middleware.js';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+
21
+ /**
22
+ * Start the Express server
23
+ * @param {object} options - Server options
24
+ * @param {string} options.repoPath - Path to git repository
25
+ * @param {number} options.port - Port to run server on
26
+ * @returns {Promise<{server: object, actualPort: number}>}
27
+ */
28
+ export async function startServer(options) {
29
+ const { repoPath, port } = options;
30
+
31
+ // Set repo path in config
32
+ appConfig.setRepoPath(repoPath);
33
+
34
+ // Create Express app
35
+ const app = express();
36
+
37
+ // Security middleware
38
+ app.use(helmet({
39
+ contentSecurityPolicy: false, // Allow inline scripts for CDN libraries
40
+ }));
41
+
42
+ // CORS middleware
43
+ app.use(cors(appConfig.getCorsConfig()));
44
+
45
+ // Parse JSON bodies
46
+ app.use(express.json({ limit: '10mb' }));
47
+
48
+ // Serve static files
49
+ const publicPath = join(__dirname, '../public');
50
+ app.use(express.static(publicPath));
51
+
52
+ // API Routes
53
+
54
+ // Health check endpoint
55
+ app.get('/api/health', (req, res) => {
56
+ res.json({
57
+ status: 'ok',
58
+ repoPath: appConfig.getRepoPath(),
59
+ });
60
+ });
61
+
62
+ // Get version from package.json
63
+ app.get('/api/version', (req, res) => {
64
+ try {
65
+ const packageJsonPath = join(__dirname, '../package.json');
66
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
67
+ res.json({
68
+ version: packageJson.version,
69
+ name: packageJson.name,
70
+ });
71
+ } catch (error) {
72
+ res.status(500).json({ error: 'Failed to read version' });
73
+ }
74
+ });
75
+
76
+ // Get repository information
77
+ app.get('/api/repo-info', commitController.getRepoInfo);
78
+
79
+ // Check if commit exists
80
+ app.get('/api/check-commit/:commitHash', commitController.checkCommit);
81
+
82
+ // Validate commits
83
+ app.post(
84
+ '/api/validate',
85
+ validateBody(validateCommitsSchema),
86
+ commitController.validateCommits,
87
+ );
88
+
89
+ // Generate cherry-pick command
90
+ app.post(
91
+ '/api/generate-command',
92
+ validateBody(generateCommandSchema),
93
+ commitController.generateCommand,
94
+ );
95
+
96
+ // 404 handler
97
+ app.use(notFoundHandler);
98
+
99
+ // Error handling middleware (must be last)
100
+ app.use(errorHandler);
101
+
102
+ // Start server with port fallback logic
103
+ return new Promise((resolve, reject) => {
104
+ const tryPort = (portToTry) => {
105
+ const server = app.listen(portToTry, () => {
106
+ resolve({ server, actualPort: portToTry });
107
+ }).on('error', (err) => {
108
+ if (err.code === 'EADDRINUSE') {
109
+ // Port is in use, try random port in fallback range
110
+ const { min, max } = appConfig.getServerConfig().fallbackPortRange;
111
+ const randomPort = Math.floor(Math.random() * (max - min + 1)) + min;
112
+ tryPort(randomPort);
113
+ } else {
114
+ reject(err);
115
+ }
116
+ });
117
+ };
118
+
119
+ tryPort(port);
120
+ });
121
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Git Service (Repository Pattern)
3
+ *
4
+ * Abstracts all git operations and provides a clean interface
5
+ * for interacting with git repositories.
6
+ */
7
+
8
+ import { execSync } from 'child_process';
9
+ import appConfig from '../config/app.config.js';
10
+
11
+ class GitService {
12
+ constructor(repoPath) {
13
+ this.repoPath = repoPath || appConfig.getRepoPath();
14
+ this.timeout = appConfig.getGitConfig().timeout;
15
+ }
16
+
17
+ /**
18
+ * Execute a git command
19
+ * @param {string} command - Git command to execute
20
+ * @returns {string} Command output
21
+ * @private
22
+ */
23
+ _execGit(command) {
24
+ try {
25
+ return execSync(command, {
26
+ cwd: this.repoPath,
27
+ encoding: 'utf-8',
28
+ timeout: this.timeout,
29
+ stdio: 'pipe',
30
+ }).trim();
31
+ } catch (error) {
32
+ throw new Error(`Git command failed: ${error.message}`);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Check if a path is a valid git repository (static method)
38
+ * @param {string} path - Path to check
39
+ * @returns {boolean} True if valid git repo
40
+ */
41
+ static checkIfGitRepo(path) {
42
+ try {
43
+ execSync('git rev-parse --git-dir', {
44
+ cwd: path,
45
+ stdio: 'pipe',
46
+ });
47
+ return true;
48
+ } catch (error) {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Validate if a commit hash exists in the repository
55
+ * @param {string} commitHash - Commit hash to validate
56
+ * @returns {boolean} True if commit exists
57
+ */
58
+ validateCommit(commitHash) {
59
+ try {
60
+ const result = this._execGit(`git cat-file -t ${commitHash}`);
61
+ return result === 'commit';
62
+ } catch (error) {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get detailed information about a commit
69
+ * @param {string} commitHash - Commit hash
70
+ * @returns {object|null} Commit information or null if not found
71
+ */
72
+ getCommitInfo(commitHash) {
73
+ try {
74
+ // Get commit details in a specific format
75
+ const format = '%ci|%an|%ae|%s|%H';
76
+ const output = this._execGit(`git show -s --format="${format}" ${commitHash}`);
77
+
78
+ const [date, author, email, message, hash] = output.split('|');
79
+
80
+ return {
81
+ hash,
82
+ date: new Date(date).toISOString(),
83
+ author,
84
+ email,
85
+ message,
86
+ shortHash: hash.substring(0, 7),
87
+ };
88
+ } catch (error) {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Get current branch name
95
+ * @returns {string} Branch name
96
+ */
97
+ getCurrentBranch() {
98
+ try {
99
+ return this._execGit('git rev-parse --abbrev-ref HEAD');
100
+ } catch (error) {
101
+ return 'unknown';
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Get repository name/identifier
107
+ * @returns {string} Repository name
108
+ */
109
+ getRepoName() {
110
+ try {
111
+ // Try to get from remote URL first
112
+ const remoteUrl = this._execGit('git config --get remote.origin.url');
113
+ const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
114
+ if (match) {
115
+ return match[1];
116
+ }
117
+ } catch (error) {
118
+ // No remote, use directory name
119
+ }
120
+
121
+ // Fallback to directory name
122
+ return this.repoPath.split('/').filter(Boolean).pop();
123
+ }
124
+
125
+ /**
126
+ * Get repository information
127
+ * @returns {object} Repository information
128
+ */
129
+ getRepoInfo() {
130
+ try {
131
+ const branch = this.getCurrentBranch();
132
+ const lastCommit = this._execGit('git log -1 --format="%H|%s|%ci"');
133
+ const [hash, message, date] = lastCommit.split('|');
134
+
135
+ let remoteName = 'No remote';
136
+ let remoteUrl = null;
137
+
138
+ try {
139
+ remoteUrl = this._execGit('git config --get remote.origin.url');
140
+ // Extract repo name from URL
141
+ const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
142
+ if (match) {
143
+ remoteName = match[1];
144
+ }
145
+ } catch (error) {
146
+ // No remote configured
147
+ }
148
+
149
+ return {
150
+ path: this.repoPath,
151
+ branch,
152
+ remoteName,
153
+ remoteUrl,
154
+ repoName: this.getRepoName(),
155
+ lastCommit: {
156
+ hash: hash.substring(0, 7),
157
+ message,
158
+ date: new Date(date).toISOString(),
159
+ },
160
+ };
161
+ } catch (error) {
162
+ throw new Error(`Failed to get repository info: ${error.message}`);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Sort commits by date (chronological order)
168
+ * @param {string[]} commits - Array of commit hashes
169
+ * @returns {string[]} Sorted commit hashes
170
+ */
171
+ sortCommitsByDate(commits) {
172
+ try {
173
+ if (commits.length === 0) return [];
174
+
175
+ // Get commit dates
176
+ const command = `git show -s --format="%ci %H" ${commits.join(' ')}`;
177
+ const output = this._execGit(command);
178
+
179
+ // Parse and sort by date
180
+ const commitData = output
181
+ .split('\n')
182
+ .map((line) => {
183
+ const parts = line.split(' ');
184
+ const date = parts.slice(0, 3).join(' ');
185
+ const hash = parts[3];
186
+ return { date, hash };
187
+ })
188
+ .sort((a, b) => new Date(a.date) - new Date(b.date));
189
+
190
+ return commitData.map((item) => item.hash);
191
+ } catch (error) {
192
+ throw new Error(`Failed to sort commits: ${error.message}`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Generate cherry-pick command
198
+ * @param {string[]} commits - Array of commit hashes (will be sorted)
199
+ * @param {object} options - Options for cherry-pick
200
+ * @returns {string} Cherry-pick command
201
+ */
202
+ generateCherryPickCommand(commits, options = {}) {
203
+ if (commits.length === 0) {
204
+ throw new Error('No commits provided');
205
+ }
206
+
207
+ // Sort commits chronologically
208
+ const sortedCommits = this.sortCommitsByDate(commits);
209
+
210
+ // Build command
211
+ let command = 'git cherry-pick';
212
+
213
+ if (options.allowEmpty) {
214
+ command += ' --allow-empty';
215
+ }
216
+
217
+ if (options.continueOnConflict) {
218
+ command += ' --continue';
219
+ }
220
+
221
+ command += ` ${sortedCommits.join(' ')}`;
222
+
223
+ return command;
224
+ }
225
+
226
+ /**
227
+ * Execute cherry-pick command (optional - for Phase 6)
228
+ * @param {string[]} commits - Array of commit hashes
229
+ * @param {object} options - Options for cherry-pick
230
+ * @returns {object} Execution result
231
+ */
232
+ executeCherryPick(commits, options = {}) {
233
+ try {
234
+ const command = this.generateCherryPickCommand(commits, options);
235
+ const output = this._execGit(command);
236
+
237
+ return {
238
+ success: true,
239
+ output,
240
+ command,
241
+ };
242
+ } catch (error) {
243
+ return {
244
+ success: false,
245
+ error: error.message,
246
+ command: this.generateCherryPickCommand(commits, options),
247
+ };
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Get list of files changed in commits
253
+ * @param {string[]} commits - Array of commit hashes
254
+ * @returns {string[]} Array of unique file paths
255
+ */
256
+ getChangedFiles(commits) {
257
+ try {
258
+ if (commits.length === 0) return [];
259
+
260
+ // Get list of files changed in all commits using git show
261
+ const command = `git show --name-only --pretty=format: ${commits.join(' ')}`;
262
+ const output = this._execGit(command);
263
+
264
+ // Split by newline and remove duplicates
265
+ const files = output
266
+ .split('\n')
267
+ .filter((file) => file.trim() !== '')
268
+ .filter((file, index, self) => self.indexOf(file) === index);
269
+
270
+ return files;
271
+ } catch (error) {
272
+ throw new Error(`Failed to get changed files: ${error.message}`);
273
+ }
274
+ }
275
+ }
276
+
277
+ export default GitService;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Validation Service
3
+ *
4
+ * Orchestrates validation of multiple commits and provides
5
+ * summary and grouping functionality.
6
+ */
7
+
8
+ import { validateCommit } from '../validators/commit.validator.js';
9
+ import GitService from './git.service.js';
10
+
11
+ class ValidationService {
12
+ constructor(repoPath) {
13
+ this.gitService = new GitService(repoPath);
14
+ }
15
+
16
+ /**
17
+ * Validate multiple commits
18
+ * @param {string[]} commits - Array of commit hashes
19
+ * @returns {Promise<object>} Validation results
20
+ */
21
+ async validateCommits(commits) {
22
+ if (!Array.isArray(commits) || commits.length === 0) {
23
+ return {
24
+ results: [],
25
+ summary: {
26
+ total: 0,
27
+ valid: 0,
28
+ invalid: 0,
29
+ canBeIgnored: 0,
30
+ },
31
+ };
32
+ }
33
+
34
+ // Validate each commit
35
+ const validationPromises = commits.map((commit) => validateCommit(commit, { gitService: this.gitService }));
36
+
37
+ const results = await Promise.all(validationPromises);
38
+
39
+ // Generate summary
40
+ const summary = this.generateSummary(results);
41
+
42
+ return {
43
+ results,
44
+ summary,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Generate validation summary
50
+ * @param {array} results - Validation results
51
+ * @returns {object} Summary statistics
52
+ * @private
53
+ */
54
+ generateSummary(results) {
55
+ const summary = {
56
+ total: results.length,
57
+ valid: 0,
58
+ invalid: 0,
59
+ canBeIgnored: 0,
60
+ };
61
+
62
+ results.forEach((result) => {
63
+ if (result.isValid) {
64
+ summary.valid++;
65
+ } else {
66
+ summary.invalid++;
67
+ if (result.canIgnore) {
68
+ summary.canBeIgnored++;
69
+ }
70
+ }
71
+ });
72
+
73
+ return summary;
74
+ }
75
+
76
+ /**
77
+ * Group validation results by status
78
+ * @param {array} results - Validation results
79
+ * @returns {object} Grouped results
80
+ */
81
+ groupValidationResults(results) {
82
+ return {
83
+ valid: results.filter((r) => r.isValid),
84
+ invalid: results.filter((r) => !r.isValid && !r.canIgnore),
85
+ ignorable: results.filter((r) => !r.isValid && r.canIgnore),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Filter commits by validity
91
+ * @param {array} results - Validation results
92
+ * @param {array} ignoredCommits - Commits to ignore (optional)
93
+ * @returns {array} Valid commit hashes
94
+ */
95
+ getValidCommits(results, ignoredCommits = []) {
96
+ return results
97
+ .filter((r) => r.isValid && !ignoredCommits.includes(r.commit))
98
+ .map((r) => r.commit);
99
+ }
100
+ }
101
+
102
+ export default ValidationService;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Error Handler Utilities
3
+ *
4
+ * Custom error classes and error handling utilities
5
+ */
6
+
7
+ /**
8
+ * Base application error class
9
+ */
10
+ export class AppError extends Error {
11
+ constructor(message, statusCode = 500) {
12
+ super(message);
13
+ this.statusCode = statusCode;
14
+ this.isOperational = true;
15
+ Error.captureStackTrace(this, this.constructor);
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Validation error (400)
21
+ */
22
+ export class ValidationError extends AppError {
23
+ constructor(message) {
24
+ super(message, 400);
25
+ this.name = 'ValidationError';
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Git operation error (500)
31
+ */
32
+ export class GitError extends AppError {
33
+ constructor(message) {
34
+ super(message, 500);
35
+ this.name = 'GitError';
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Not found error (404)
41
+ */
42
+ export class NotFoundError extends AppError {
43
+ constructor(message) {
44
+ super(message, 404);
45
+ this.name = 'NotFoundError';
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Serialize error for API response
51
+ * @param {Error} error - Error object
52
+ * @returns {object} Serialized error
53
+ */
54
+ export function serializeError(error) {
55
+ if (error instanceof AppError) {
56
+ return {
57
+ error: error.name,
58
+ message: error.message,
59
+ statusCode: error.statusCode,
60
+ };
61
+ }
62
+
63
+ // Unknown error
64
+ return {
65
+ error: 'InternalServerError',
66
+ message: 'An unexpected error occurred',
67
+ statusCode: 500,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Async handler wrapper to catch errors in async route handlers
73
+ * @param {Function} fn - Async function
74
+ * @returns {Function} Wrapped function
75
+ */
76
+ export function asyncHandler(fn) {
77
+ return (req, res, next) => {
78
+ Promise.resolve(fn(req, res, next)).catch(next);
79
+ };
80
+ }