feat: add Sheet component for bottom sheet UI with drag-to-dismiss and snap points

test: implement DialogStyleContext tests for preference management and style recommendations

test: create tests for useAutoSelection hook, including countdown and pause functionality

feat: implement useAutoSelection hook for enhanced auto-selection with sound notifications

feat: create Zustand store for managing issue submission wizard state

feat: add Zod validation schemas for issue-related API requests

feat: implement issue service for CRUD operations and validation handling

feat: define TypeScript types for issue submission and management
This commit is contained in:
catlog22
2026-02-16 11:51:21 +08:00
parent 374a1e1c2c
commit 2202c2ccfd
35 changed files with 3717 additions and 145 deletions

View File

@@ -7,13 +7,16 @@
* ├── queues/ # Queue history directory
* │ ├── index.json # Queue index (active + history)
* │ └── {queue-id}.json # Individual queue files
* ── solutions/
* ├── {issue-id}.jsonl # Solutions for issue (one per line)
* └── ...
* ── solutions/
* ├── {issue-id}.jsonl # Solutions for issue (one per line)
* └── ...
* └── attachments/
* └── {issue-id}/ # Attachments for issue
* └── {filename} # Uploaded files
*
* API Endpoints (8 total):
* API Endpoints:
* - GET /api/issues - List all issues
* - POST /api/issues - Create new issue
* - POST /api/issues - Create new issue (with Zod validation)
* - GET /api/issues/:id - Get issue detail
* - PATCH /api/issues/:id - Update issue (includes binding logic)
* - DELETE /api/issues/:id - Delete issue
@@ -21,10 +24,21 @@
* - PATCH /api/issues/:id/tasks/:taskId - Update task
* - GET /api/queue - Get execution queue
* - POST /api/queue/reorder - Reorder queue items
* - POST /api/issues/:id/attachments - Upload attachment (multipart/form-data)
* - GET /api/issues/:id/attachments - List attachments
* - DELETE /api/issues/:id/attachments/:attachmentId - Delete attachment
* - GET /api/issues/files/:issueId/:filename - Download file
*/
import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
import { join, resolve, normalize } from 'path';
import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync, createReadStream, statSync } from 'fs';
import { join, resolve, normalize, basename } from 'path';
import { randomUUID } from 'crypto';
import type { RouteContext } from './types.js';
import {
processCreateIssueRequest,
generateIssueId,
type CreateIssueResult,
} from '../services/issue-service.js';
import type { Issue, Attachment } from '../types/issue.js';
// ========== JSONL Helper Functions ==========
@@ -85,6 +99,117 @@ function generateQueueFileId(): string {
return `QUE-${ts}`;
}
// ========== Attachment Helper Functions ==========
const ALLOWED_MIME_TYPES = [
// Images
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml',
// Documents
'application/pdf',
'text/plain', 'text/markdown', 'text/csv',
// Code files
'application/json', 'text/javascript', 'text/typescript', 'text/html', 'text/css',
'application/xml', 'text/xml',
// Archives
'application/zip', 'application/x-gzip',
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
function getAttachmentsDir(issuesDir: string, issueId: string): string {
return join(issuesDir, 'attachments', issueId);
}
function sanitizeFilename(filename: string): string {
// Remove path traversal attempts and invalid characters
const sanitized = basename(filename)
.replace(/[<>:"|?*\x00-\x1f]/g, '')
.replace(/\.\./g, '');
// Add timestamp prefix to prevent collisions
const ext = sanitized.includes('.') ? `.${sanitized.split('.').pop()}` : '';
const base = ext ? sanitized.slice(0, -(ext.length)) : sanitized;
return `${Date.now()}-${base}${ext}`;
}
function isValidMimeType(mimeType: string): boolean {
// Allow common code file types that might not have standard MIME types
const additionalTypes = [
'application/octet-stream', // Generic binary, often used for various file types
];
return ALLOWED_MIME_TYPES.includes(mimeType) || additionalTypes.includes(mimeType);
}
function parseMultipartFormData(req: any): Promise<{ fields: Record<string, string>; files: Array<{ name: string; data: Buffer; filename: string; type: string }> }> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
try {
const boundary = req.headers['content-type']?.match(/boundary=(.+)/)?.[1];
if (!boundary) {
reject(new Error('No boundary in content-type'));
return;
}
const buffer = Buffer.concat(chunks);
const boundaryBuffer = Buffer.from(`--${boundary}`);
const fields: Record<string, string> = {};
const files: Array<{ name: string; data: Buffer; filename: string; type: string }> = [];
// Split by boundary
let start = 0;
while (start < buffer.length) {
const boundaryIndex = buffer.indexOf(boundaryBuffer, start);
if (boundaryIndex === -1) break;
const nextBoundary = buffer.indexOf(boundaryBuffer, boundaryIndex + boundaryBuffer.length);
if (nextBoundary === -1) break;
const part = buffer.slice(boundaryIndex + boundaryBuffer.length + 2, nextBoundary - 2); // +2 for \r\n, -2 for \r\n before boundary
// Parse headers
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) {
start = nextBoundary;
continue;
}
const headers = part.slice(0, headerEnd).toString();
const content = part.slice(headerEnd + 4);
// Extract content-disposition
const nameMatch = headers.match(/name="([^"]+)"/);
const filenameMatch = headers.match(/filename="([^"]+)"/);
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
const name = nameMatch[1];
if (filenameMatch) {
// It's a file
files.push({
name,
data: content,
filename: filenameMatch[1],
type: contentTypeMatch?.[1] || 'application/octet-stream',
});
} else {
// It's a field
fields[name] = content.toString().replace(/\r\n$/, '');
}
}
start = nextBoundary;
}
resolve({ fields, files });
} catch (err) {
reject(err);
}
});
req.on('error', reject);
});
}
function readQueue(issuesDir: string) {
// Try new multi-queue structure first
const queuesDir = join(issuesDir, 'queues');
@@ -1140,30 +1265,31 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// POST /api/issues - Create issue
// POST /api/issues - Create issue (with Zod validation)
if (pathname === '/api/issues' && req.method === 'POST') {
handlePostRequest(req, res, async (body: any) => {
if (!body.id || !body.title) return { error: 'id and title required' };
// Use new validation service
const result: CreateIssueResult = processCreateIssueRequest(body);
if (!result.success) {
return { error: result.error.error.message, status: result.status, details: result.error.error };
}
// TypeScript narrowing: result is now { success: true; issue: Issue; status: 201 }
const { issue } = result;
const issues = readIssuesJsonl(issuesDir);
if (issues.find(i => i.id === body.id)) return { error: `Issue ${body.id} exists` };
const newIssue = {
id: body.id,
title: body.title,
status: body.status || 'registered',
priority: body.priority || 3,
context: body.context || '',
source: body.source || 'text',
source_url: body.source_url || null,
tags: body.tags || [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
// Check for duplicate ID (auto-generated IDs should be unique)
if (issues.find((i: any) => i.id === issue.id)) {
return { error: `Issue ${issue.id} already exists`, status: 409 };
}
issues.push(newIssue);
// Store issue
issues.push(issue);
writeIssuesJsonl(issuesDir, issues);
return { success: true, issue: newIssue };
// Return 201 Created response
return { success: true, data: { issue }, status: 201 };
});
return true;
}
@@ -1626,5 +1752,233 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// ===== Attachment Routes =====
// POST /api/issues/:id/attachments - Upload attachment
const uploadAttachmentMatch = pathname.match(/^\/api\/issues\/([^/]+)\/attachments$/);
if (uploadAttachmentMatch && req.method === 'POST') {
const issueId = decodeURIComponent(uploadAttachmentMatch[1]);
// Check if issue exists
const issues = readIssuesJsonl(issuesDir);
const issueIndex = issues.findIndex(i => i.id === issueId);
if (issueIndex === -1) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Issue not found' }));
return true;
}
// Parse multipart form data
try {
const contentType = req.headers['content-type'] || '';
if (!contentType.includes('multipart/form-data')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Content-Type must be multipart/form-data' }));
return true;
}
const { files } = await parseMultipartFormData(req);
if (files.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'No files uploaded' }));
return true;
}
const uploadedAttachments: Attachment[] = [];
const attachmentsDir = getAttachmentsDir(issuesDir, issueId);
if (!existsSync(attachmentsDir)) {
mkdirSync(attachmentsDir, { recursive: true });
}
for (const file of files) {
// Validate file size
if (file.data.length > MAX_FILE_SIZE) {
continue; // Skip files that are too large
}
// Validate MIME type (allow common types)
if (!isValidMimeType(file.type)) {
continue; // Skip invalid file types
}
// Generate safe filename
const safeFilename = sanitizeFilename(file.filename);
const filePath = join(attachmentsDir, safeFilename);
// Save file
writeFileSync(filePath, file.data);
// Create attachment record
const attachment: Attachment = {
id: randomUUID(),
filename: file.filename,
path: `attachments/${issueId}/${safeFilename}`,
type: file.type,
size: file.data.length,
uploaded_at: new Date().toISOString(),
};
uploadedAttachments.push(attachment);
}
if (uploadedAttachments.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'No valid files uploaded. Check file size (max 10MB) and type.' }));
return true;
}
// Update issue with attachments
if (!issues[issueIndex].attachments) {
issues[issueIndex].attachments = [];
}
issues[issueIndex].attachments!.push(...uploadedAttachments);
issues[issueIndex].updated_at = new Date().toISOString();
writeIssuesJsonl(issuesDir, issues);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
issueId,
attachments: uploadedAttachments,
count: uploadedAttachments.length,
}));
} catch (err: any) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message || 'Failed to upload attachments' }));
}
return true;
}
// GET /api/issues/:id/attachments - List attachments
const listAttachmentsMatch = pathname.match(/^\/api\/issues\/([^/]+)\/attachments$/);
if (listAttachmentsMatch && req.method === 'GET') {
const issueId = decodeURIComponent(listAttachmentsMatch[1]);
const issues = readIssuesJsonl(issuesDir);
const issue = issues.find(i => i.id === issueId);
if (!issue) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Issue not found' }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
issueId,
attachments: issue.attachments || [],
count: (issue.attachments || []).length,
}));
return true;
}
// DELETE /api/issues/:id/attachments/:attachmentId - Delete attachment
const deleteAttachmentMatch = pathname.match(/^\/api\/issues\/([^/]+)\/attachments\/([^/]+)$/);
if (deleteAttachmentMatch && req.method === 'DELETE') {
const issueId = decodeURIComponent(deleteAttachmentMatch[1]);
const attachmentId = decodeURIComponent(deleteAttachmentMatch[2]);
const issues = readIssuesJsonl(issuesDir);
const issueIndex = issues.findIndex(i => i.id === issueId);
if (issueIndex === -1) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Issue not found' }));
return true;
}
const issue = issues[issueIndex];
if (!issue.attachments || issue.attachments.length === 0) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'No attachments found' }));
return true;
}
const attachmentIndex = issue.attachments.findIndex((a: Attachment) => a.id === attachmentId);
if (attachmentIndex === -1) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Attachment not found' }));
return true;
}
const attachment = issue.attachments[attachmentIndex];
// Delete file from disk
const filePath = join(issuesDir, attachment.path);
if (existsSync(filePath)) {
try {
unlinkSync(filePath);
} catch {
// Ignore file deletion errors
}
}
// Remove from issue
issue.attachments.splice(attachmentIndex, 1);
issue.updated_at = new Date().toISOString();
writeIssuesJsonl(issuesDir, issues);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
issueId,
deletedAttachmentId: attachmentId,
}));
return true;
}
// GET /api/issues/files/:issueId/:filename - Get/download file
const fileMatch = pathname.match(/^\/api\/issues\/files\/([^/]+)\/(.+)$/);
if (fileMatch && req.method === 'GET') {
const issueId = decodeURIComponent(fileMatch[1]);
const filename = decodeURIComponent(fileMatch[2]);
// Verify the file belongs to this issue
const issues = readIssuesJsonl(issuesDir);
const issue = issues.find(i => i.id === issueId);
if (!issue) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Issue not found' }));
return true;
}
// Find attachment by filename (check both original and sanitized name)
const attachment = (issue.attachments || []).find((a: Attachment) =>
a.path.endsWith(filename) ||
a.filename === filename
);
if (!attachment) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found' }));
return true;
}
const filePath = join(issuesDir, attachment.path);
if (!existsSync(filePath)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found on disk' }));
return true;
}
try {
const stat = statSync(filePath);
res.writeHead(200, {
'Content-Type': attachment.type,
'Content-Length': stat.size,
'Content-Disposition': `attachment; filename="${encodeURIComponent(attachment.filename)}"`,
});
const fileStream = createReadStream(filePath);
fileStream.pipe(res);
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read file' }));
}
return true;
}
return false;
}

View File

@@ -0,0 +1,156 @@
/**
* Issue Zod Validation Schemas
* Provides runtime validation for Issue-related API requests
*/
import { z } from 'zod';
/**
* Issue type enum schema
*/
export const IssueTypeSchema = z.enum(['bug', 'feature', 'improvement', 'other']);
/**
* Issue priority enum schema
*/
export const IssuePrioritySchema = z.enum(['low', 'medium', 'high', 'urgent']);
/**
* Issue source enum schema
*/
export const IssueSourceSchema = z.enum(['text', 'github', 'file']);
/**
* Attachment schema for file uploads
*/
export const AttachmentSchema = z.object({
id: z.string().uuid(),
filename: z.string().max(255),
path: z.string(),
type: z.string().max(100),
size: z.number().int().nonnegative(),
uploaded_at: z.string().datetime(),
});
/**
* Create Issue request schema with validation rules
* - title: required, max 200 characters
* - description: required, max 10000 characters
* - type: optional enum
* - priority: optional enum
* - attachments: optional array of attachments
*/
export const CreateIssueRequestSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(200, 'Title must be at most 200 characters')
.trim(),
description: z.string()
.min(1, 'Description is required')
.max(10000, 'Description must be at most 10000 characters'),
type: IssueTypeSchema.optional()
.default('other'),
priority: IssuePrioritySchema.optional()
.default('medium'),
context: z.string()
.max(5000, 'Context must be at most 5000 characters')
.optional()
.default(''),
source: IssueSourceSchema.optional()
.default('text'),
source_url: z.string()
.url('Source URL must be a valid URL')
.nullable()
.optional()
.default(null),
tags: z.array(z.string().max(50))
.max(10, 'Maximum 10 tags allowed')
.optional()
.default([]),
attachments: z.array(AttachmentSchema)
.max(10, 'Maximum 10 attachments allowed')
.optional()
.default([]),
});
/**
* Type inference from Zod schema
*/
export type CreateIssueRequestInput = z.infer<typeof CreateIssueRequestSchema>;
/**
* Validation result type
*/
export interface ValidationResult<T> {
success: boolean;
data?: T;
errors?: Array<{
field: string;
message: string;
code: string;
}>;
}
/**
* Validate create issue request
* Returns validated data or error details
*/
export function validateCreateIssueRequest(data: unknown): ValidationResult<CreateIssueRequestInput> {
const result = CreateIssueRequestSchema.safeParse(data);
if (result.success) {
return {
success: true,
data: result.data,
};
}
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.') || 'root',
message: issue.message,
code: issue.code,
}));
return {
success: false,
errors,
};
}
/**
* Format validation errors for API response
*/
export function formatValidationErrors(
errors: Array<{ field: string; message: string; code: string }>
): { message: string; suggestions: string[] } {
const messages = errors.map((e) => `${e.field}: ${e.message}`);
const suggestions: string[] = [];
// Add contextual suggestions based on error types
for (const error of errors) {
if (error.field === 'title' && error.code === 'too_big') {
suggestions.push('Consider using a shorter, more descriptive title');
}
if (error.field === 'description' && error.code === 'too_big') {
suggestions.push('Move detailed information to context field or attachments');
}
if (error.field === 'type' && error.code === 'invalid_enum_value') {
suggestions.push('Valid types are: bug, feature, improvement, other');
}
if (error.field === 'priority' && error.code === 'invalid_enum_value') {
suggestions.push('Valid priorities are: low, medium, high, urgent');
}
}
return {
message: `Validation failed: ${messages.join('; ')}`,
suggestions: suggestions.length > 0 ? suggestions : ['Check your request body format'],
};
}

View File

@@ -93,7 +93,11 @@ function handlePostRequest(req: http.IncomingMessage, res: http.ServerResponse,
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
// Support custom success status codes (e.g., 201 Created)
const successStatus = typeof statusValue === 'number' && statusValue >= 200 && statusValue < 300
? statusValue
: 200;
res.writeHead(successStatus, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);

View File

@@ -0,0 +1,147 @@
/**
* Issue Service
* Business logic for Issue CRUD operations
*/
import { randomUUID } from 'crypto';
import type {
Issue,
CreateIssueRequest,
ApiResponse,
ApiErrorResponse,
ValidationErrorResponse,
IssueType,
IssuePriority,
IssueStatus,
} from '../types/issue.js';
import {
validateCreateIssueRequest,
formatValidationErrors,
type CreateIssueRequestInput,
} from '../schemas/issue-schema.js';
/**
* Generate a unique Issue ID
* Format: ISS-{timestamp}-{random}
*/
export function generateIssueId(): string {
const timestamp = Date.now().toString(36);
const random = randomUUID().split('-')[0];
return `ISS-${timestamp}-${random}`;
}
/**
* Map validated input to Issue entity
*/
export function createIssueEntity(input: CreateIssueRequestInput, customId?: string): Issue {
const now = new Date().toISOString();
return {
id: customId || generateIssueId(),
title: input.title,
description: input.description,
type: input.type as IssueType,
priority: input.priority as IssuePriority,
status: 'registered' as IssueStatus,
context: input.context || '',
source: input.source || 'text',
source_url: input.source_url || null,
tags: input.tags || [],
attachments: input.attachments || [],
created_at: now,
updated_at: now,
};
}
/**
* Create success response
*/
export function createSuccessResponse<T>(data: T): ApiResponse<T> {
return {
success: true,
data,
};
}
/**
* Create error response
*/
export function createErrorResponse(
code: string,
message: string,
suggestions?: string[]
): ApiErrorResponse {
return {
success: false,
error: {
code,
message,
suggestions,
},
};
}
/**
* Create validation error response with field details
*/
export function createValidationErrorResponse(
errors: Array<{ field: string; message: string; code: string }>
): ValidationErrorResponse {
const formatted = formatValidationErrors(errors);
return {
success: false,
error: {
code: 'VALIDATION_ERROR',
message: formatted.message,
details: errors,
suggestions: formatted.suggestions,
},
};
}
/**
* Service result type for create operation
*/
export type CreateIssueResult =
| { success: true; issue: Issue; status: 201 }
| { success: false; error: ValidationErrorResponse | ApiErrorResponse; status: 400 };
/**
* Process create issue request with validation
*/
export function processCreateIssueRequest(
body: unknown,
options?: { customId?: string }
): CreateIssueResult {
// Validate request body
const validation = validateCreateIssueRequest(body);
if (!validation.success || !validation.data) {
return {
success: false,
error: createValidationErrorResponse(validation.errors || []),
status: 400,
};
}
// Create issue entity
const issue = createIssueEntity(validation.data, options?.customId);
return {
success: true,
issue,
status: 201,
};
}
/**
* Check if an issue ID already exists
*/
export function isIssueIdExists(issues: Issue[], id: string): boolean {
return issues.some((issue) => issue.id === id);
}
/**
* Validate issue ID format
*/
export function isValidIssueId(id: string): boolean {
return /^ISS-[a-z0-9]+-[a-z0-9]+$/i.test(id);
}

113
ccw/src/core/types/issue.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* Issue Type Definitions
* TypeScript types for Issue submission and management
*/
/**
* Issue type enum values
*/
export type IssueType = 'bug' | 'feature' | 'improvement' | 'other';
/**
* Issue priority enum values
*/
export type IssuePriority = 'low' | 'medium' | 'high' | 'urgent';
/**
* Issue status enum values
*/
export type IssueStatus = 'registered' | 'analyzing' | 'planned' | 'executing' | 'completed' | 'cancelled';
/**
* Attachment entity for file uploads
*/
export interface Attachment {
id: string; // UUID
filename: string; // 原始文件名
path: string; // 相对存储路径
type: string; // MIME类型
size: number; // 文件大小(bytes)
uploaded_at: string; // ISO时间戳
}
/**
* Create Issue Request DTO
* Required fields: title, description
*/
export interface CreateIssueRequest {
title: string;
description: string;
type?: IssueType;
priority?: IssuePriority;
context?: string;
source?: 'text' | 'github' | 'file';
source_url?: string | null;
tags?: string[];
attachments?: Attachment[];
}
/**
* Issue entity stored in persistence layer
*/
export interface Issue {
id: string;
title: string;
description: string;
type: IssueType;
priority: IssuePriority;
status: IssueStatus;
context: string;
source: 'text' | 'github' | 'file';
source_url: string | null;
tags: string[];
attachments?: Attachment[];
created_at: string;
updated_at: string;
}
/**
* API success response wrapper
*/
export interface ApiResponse<T = unknown> {
success: true;
data: T;
}
/**
* API error response wrapper
*/
export interface ApiErrorResponse {
success: false;
error: {
code: string;
message: string;
suggestions?: string[];
};
}
/**
* Union type for all API responses
*/
export type ApiResult<T = unknown> = ApiResponse<T> | ApiErrorResponse;
/**
* Validation error detail
*/
export interface ValidationErrorDetail {
field: string;
message: string;
code: string;
}
/**
* Validation error response with detailed field errors
*/
export interface ValidationErrorResponse {
success: false;
error: {
code: 'VALIDATION_ERROR';
message: string;
details: ValidationErrorDetail[];
suggestions: string[];
};
}