diff --git a/migrations/006_version_git_tag.sql b/migrations/006_version_git_tag.sql new file mode 100644 index 0000000..0502f92 --- /dev/null +++ b/migrations/006_version_git_tag.sql @@ -0,0 +1,6 @@ +-- Migration 006: Add git_tag and git_sha columns to versions table +-- Links versions to git tags/releases + +ALTER TABLE versions ADD COLUMN IF NOT EXISTS git_tag TEXT; +ALTER TABLE versions ADD COLUMN IF NOT EXISTS git_sha TEXT; +CREATE INDEX IF NOT EXISTS idx_versions_git_tag ON versions(git_tag); diff --git a/migrations/007_task_activity.sql b/migrations/007_task_activity.sql new file mode 100644 index 0000000..7666e18 --- /dev/null +++ b/migrations/007_task_activity.sql @@ -0,0 +1,16 @@ +-- Migration 007: Task activity tracking for session integration +-- Tracks which tasks were touched per session for audit trail + +CREATE TABLE IF NOT EXISTS task_activity ( + id SERIAL PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + session_id TEXT NOT NULL, + activity_type TEXT NOT NULL CHECK (activity_type IN ('created', 'updated', 'status_change', 'closed')), + old_value TEXT, + new_value TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_task_activity_session ON task_activity(session_id); +CREATE INDEX IF NOT EXISTS idx_task_activity_task ON task_activity(task_id); +CREATE INDEX IF NOT EXISTS idx_task_activity_time ON task_activity(created_at DESC); diff --git a/migrations/008_task_commits.sql b/migrations/008_task_commits.sql new file mode 100644 index 0000000..d221aaf --- /dev/null +++ b/migrations/008_task_commits.sql @@ -0,0 +1,16 @@ +-- Migration 008: Task-commit linking +-- Links tasks to git commits via SHA references (Gitea MCP owns full commit data) + +CREATE TABLE IF NOT EXISTS task_commits ( + id SERIAL PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + commit_sha TEXT NOT NULL, + repo TEXT NOT NULL, + source TEXT DEFAULT 'manual' CHECK (source IN ('manual', 'parsed', 'pr_merge')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(task_id, commit_sha) +); + +CREATE INDEX IF NOT EXISTS idx_task_commits_task ON task_commits(task_id); +CREATE INDEX IF NOT EXISTS idx_task_commits_sha ON task_commits(commit_sha); +CREATE INDEX IF NOT EXISTS idx_task_commits_repo ON task_commits(repo); diff --git a/src/index.ts b/src/index.ts index 41179ac..a56fc5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from '. import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js'; import { taskDelegations, taskDelegationQuery } from './tools/delegations.js'; import { projectLock, projectUnlock, projectLockStatus, projectContext } from './tools/locks.js'; +import { versionAdd, versionList, versionShow, versionUpdate, versionRelease, versionAssignTask } from './tools/versions.js'; +import { taskCommitAdd, taskCommitRemove, taskCommitsList, taskLinkCommits, sessionTasks } from './tools/commits.js'; // Create MCP server const server = new Server( @@ -189,6 +191,81 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { result = await projectContext(); break; + // Versions + case 'version_add': + result = await versionAdd({ + project: a.project, + version: a.version, + build_number: a.build_number, + status: a.status, + release_notes: a.release_notes, + }); + break; + case 'version_list': + result = await versionList({ + project: a.project, + status: a.status, + limit: a.limit, + }); + break; + case 'version_show': + result = await versionShow(a.id); + break; + case 'version_update': + result = await versionUpdate({ + id: a.id, + status: a.status, + git_tag: a.git_tag, + git_sha: a.git_sha, + release_notes: a.release_notes, + release_date: a.release_date, + }); + break; + case 'version_release': + result = await versionRelease({ + id: a.id, + git_tag: a.git_tag, + }); + break; + case 'version_assign_task': + result = await versionAssignTask({ + task_id: a.task_id, + version_id: a.version_id, + }); + break; + + // Commits + case 'task_commit_add': + result = await taskCommitAdd({ + task_id: a.task_id, + commit_sha: a.commit_sha, + repo: a.repo, + source: a.source, + }); + break; + case 'task_commit_remove': + result = await taskCommitRemove({ + task_id: a.task_id, + commit_sha: a.commit_sha, + }); + break; + case 'task_commits_list': + result = await taskCommitsList(a.task_id); + break; + case 'task_link_commits': + result = await taskLinkCommits({ + repo: a.repo, + commits: a.commits, + dry_run: a.dry_run, + }); + break; + case 'session_tasks': + result = await sessionTasks({ + session_id: a.session_id, + limit: a.limit, + }); + break; + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/tools/commits.ts b/src/tools/commits.ts new file mode 100644 index 0000000..8dbdb63 --- /dev/null +++ b/src/tools/commits.ts @@ -0,0 +1,255 @@ +// Commit linking operations for task-mcp +// Links tasks to git commits via SHA references (Gitea MCP owns full commit data) + +import { query, queryOne, execute } from '../db.js'; +import type { TaskCommit } from '../types.js'; + +interface TaskCommitAddArgs { + task_id: string; + commit_sha: string; + repo: string; + source?: string; +} + +interface TaskLinkCommitsArgs { + repo: string; + commits: Array<{ sha: string; message: string }>; + dry_run?: boolean; +} + +/** + * Add a commit link to a task + */ +export async function taskCommitAdd(args: TaskCommitAddArgs): Promise { + const { task_id, commit_sha, repo, source = 'manual' } = args; + + // Verify task exists + const task = await queryOne<{ id: string }>(`SELECT id FROM tasks WHERE id = $1`, [task_id]); + if (!task) { + return `Task not found: ${task_id}`; + } + + // Check if link already exists + const existing = await queryOne<{ id: number }>( + `SELECT id FROM task_commits WHERE task_id = $1 AND commit_sha = $2`, + [task_id, commit_sha] + ); + if (existing) { + return `Commit already linked: ${commit_sha.substring(0, 7)} → ${task_id}`; + } + + // Insert link + await execute( + `INSERT INTO task_commits (task_id, commit_sha, repo, source) + VALUES ($1, $2, $3, $4)`, + [task_id, commit_sha, repo, source] + ); + + return `Linked: ${commit_sha.substring(0, 7)} → ${task_id} (${repo})`; +} + +/** + * Remove a commit link from a task + */ +export async function taskCommitRemove(args: { task_id: string; commit_sha: string }): Promise { + const { task_id, commit_sha } = args; + + const result = await execute( + `DELETE FROM task_commits WHERE task_id = $1 AND commit_sha = $2`, + [task_id, commit_sha] + ); + + if (result === 0) { + return `Link not found: ${commit_sha.substring(0, 7)} → ${task_id}`; + } + + return `Unlinked: ${commit_sha.substring(0, 7)} from ${task_id}`; +} + +/** + * List commits for a task + */ +export async function taskCommitsList(task_id: string): Promise { + const commits = await query( + `SELECT commit_sha, repo, source, + to_char(created_at, 'YYYY-MM-DD HH24:MI') as created + FROM task_commits + WHERE task_id = $1 + ORDER BY created_at DESC`, + [task_id] + ); + + if (commits.length === 0) { + return `No commits linked to ${task_id}`; + } + + const lines = commits.map(c => { + const sha = c.commit_sha.substring(0, 7); + return ` - ${sha} (${c.repo}) [${c.source}] ${(c as unknown as { created: string }).created}`; + }); + + return `Commits for ${task_id}:\n${lines.join('\n')}`; +} + +/** + * Parse commit messages and link to tasks + * Patterns: "VPN-123: message", "VPN-123", "closes #123", "fixes #123", "refs #123" + */ +export async function taskLinkCommits(args: TaskLinkCommitsArgs): Promise { + const { repo, commits, dry_run = false } = args; + + // Extract project key from repo (e.g., "christian/VPN" -> "VPN") + const repoProject = repo.split('/').pop()?.toUpperCase() || ''; + + // Patterns to match task IDs + const patterns = [ + /([A-Z]{2,6})-(\d+)/g, // VPN-123, ST-45, etc. + /(?:closes?|fixes?|refs?)\s+#(\d+)/gi, // closes #123 + ]; + + const results: Array<{ commit_sha: string; task_id: string; message: string }> = []; + const errors: string[] = []; + + for (const commit of commits) { + const { sha, message } = commit; + + // Try each pattern + for (const pattern of patterns) { + pattern.lastIndex = 0; // Reset regex state + let match; + + while ((match = pattern.exec(message)) !== null) { + let task_id: string; + + if (match[2]) { + // Pattern 1: PROJECT-NUM format + task_id = `${match[1]}-${match[2]}`; + } else if (match[1]) { + // Pattern 2: Just number, assume repo project + task_id = `${repoProject}-${match[1]}`; + } else { + continue; + } + + // Verify task exists + const task = await queryOne<{ id: string }>(`SELECT id FROM tasks WHERE id = $1`, [task_id]); + if (task) { + results.push({ commit_sha: sha, task_id, message: message.substring(0, 50) }); + } + } + } + } + + if (results.length === 0) { + return `No task references found in ${commits.length} commits`; + } + + if (dry_run) { + const lines = results.map(r => + ` ${r.commit_sha.substring(0, 7)} → ${r.task_id}: "${r.message}..."` + ); + return `Would link (dry run):\n${lines.join('\n')}`; + } + + // Create links + let linked = 0; + let skipped = 0; + + for (const r of results) { + const existing = await queryOne<{ id: number }>( + `SELECT id FROM task_commits WHERE task_id = $1 AND commit_sha = $2`, + [r.task_id, r.commit_sha] + ); + + if (!existing) { + await execute( + `INSERT INTO task_commits (task_id, commit_sha, repo, source) + VALUES ($1, $2, $3, 'parsed')`, + [r.task_id, r.commit_sha, repo] + ); + linked++; + } else { + skipped++; + } + } + + let output = `Linked ${linked} commits to tasks`; + if (skipped > 0) { + output += ` (${skipped} already linked)`; + } + if (errors.length > 0) { + output += `\nErrors:\n${errors.join('\n')}`; + } + + return output; +} + +/** + * Get commits for task_show output + */ +export async function getTaskCommits(task_id: string): Promise { + const commits = await query( + `SELECT commit_sha, repo, source, + to_char(created_at, 'YYYY-MM-DD') as created + FROM task_commits + WHERE task_id = $1 + ORDER BY created_at DESC + LIMIT 5`, + [task_id] + ); + + if (commits.length === 0) { + return null; + } + + let output = '\n**Commits:**\n'; + for (const c of commits) { + const sha = c.commit_sha.substring(0, 7); + output += ` - ${sha} (${(c as unknown as { created: string }).created}) [${c.source}]\n`; + } + + return output; +} + +/** + * Add a session_tasks tool to query tasks by session + */ +export async function sessionTasks(args: { session_id: string; limit?: number }): Promise { + const { session_id, limit = 20 } = args; + + // Support wildcard matching for session prefix + const useWildcard = session_id.includes('*'); + const sessionPattern = useWildcard ? session_id.replace('*', '%') : session_id; + + const activities = await query<{ + task_id: string; + title: string; + activity_type: string; + old_value: string; + new_value: string; + created: string; + }>( + `SELECT DISTINCT ON (a.task_id) + a.task_id, t.title, a.activity_type, a.old_value, a.new_value, + to_char(a.created_at, 'YYYY-MM-DD HH24:MI') as created + FROM task_activity a + JOIN tasks t ON t.id = a.task_id + WHERE a.session_id ${useWildcard ? 'LIKE' : '='} $1 + ORDER BY a.task_id, a.created_at DESC + LIMIT $2`, + [sessionPattern, limit] + ); + + if (activities.length === 0) { + return `No tasks found for session: ${session_id}`; + } + + const lines = activities.map(a => { + const change = a.activity_type === 'status_change' + ? ` (${a.old_value} → ${a.new_value})` + : ''; + return ` - ${a.task_id}: ${a.title} [${a.activity_type}${change}]`; + }); + + return `Tasks for session ${session_id}:\n${lines.join('\n')}`; +} diff --git a/src/tools/crud.ts b/src/tools/crud.ts index 778b726..8142d44 100644 --- a/src/tools/crud.ts +++ b/src/tools/crud.ts @@ -4,6 +4,56 @@ import { query, queryOne, execute, getNextTaskId, getProjectKey } from '../db.js import { getEmbedding, formatEmbedding } from '../embeddings.js'; import type { Task, ChecklistItem, TaskLink } from '../types.js'; import { getRecentDelegations } from './delegations.js'; +import { getTaskCommits } from './commits.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Get current session ID from environment or cache file + */ +function getSessionId(): string { + // Check environment first + if (process.env.CLAUDE_SESSION_ID) { + return process.env.CLAUDE_SESSION_ID; + } + + // Try to read from cache file (usage-stats format) + const cacheFile = path.join(os.homedir(), '.claude', 'cache', '.current_session_id'); + try { + const sessionId = fs.readFileSync(cacheFile, 'utf-8').trim(); + if (sessionId) return sessionId; + } catch { + // File doesn't exist or can't be read + } + + // Generate a new session ID + const now = new Date(); + const timestamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 15); + return `session_${timestamp}`; +} + +/** + * Record task activity for session tracking + */ +async function recordActivity( + task_id: string, + activity_type: 'created' | 'updated' | 'status_change' | 'closed', + old_value?: string, + new_value?: string +): Promise { + const session_id = getSessionId(); + try { + await execute( + `INSERT INTO task_activity (task_id, session_id, activity_type, old_value, new_value) + VALUES ($1, $2, $3, $4, $5)`, + [task_id, session_id, activity_type, old_value || null, new_value || null] + ); + } catch { + // Don't fail the main operation if activity tracking fails + console.error('Failed to record task activity'); + } +} interface TaskAddArgs { title: string; @@ -61,6 +111,9 @@ export async function taskAdd(args: TaskAddArgs): Promise { ); } + // Record activity for session tracking + await recordActivity(taskId, 'created', undefined, 'open'); + return `Created: ${taskId}\n Title: ${title}\n Type: ${type}\n Priority: ${priority}\n Project: ${projectKey}${embedding ? '\n (embedded for semantic search)' : ''}`; } @@ -226,6 +279,12 @@ export async function taskShow(id: string): Promise { } } + // Get commits + const commitHistory = await getTaskCommits(id); + if (commitHistory) { + output += commitHistory; + } + // Get recent delegations const delegationHistory = await getRecentDelegations(id); if (delegationHistory) { @@ -239,6 +298,9 @@ export async function taskShow(id: string): Promise { * Close a task */ export async function taskClose(id: string): Promise { + // Get current status for activity tracking + const task = await queryOne<{ status: string }>(`SELECT status FROM tasks WHERE id = $1`, [id]); + const result = await execute( `UPDATE tasks SET status = 'completed', completed_at = NOW(), updated_at = NOW() @@ -250,6 +312,9 @@ export async function taskClose(id: string): Promise { return `Task not found: ${id}`; } + // Record activity + await recordActivity(id, 'closed', task?.status, 'completed'); + return `Closed: ${id}`; } @@ -259,6 +324,12 @@ export async function taskClose(id: string): Promise { export async function taskUpdate(args: TaskUpdateArgs): Promise { const { id, status, priority, type, title } = args; + // Get current values for activity tracking + const task = await queryOne<{ status: string }>(`SELECT status FROM tasks WHERE id = $1`, [id]); + if (!task) { + return `Task not found: ${id}`; + } + const updates: string[] = []; const params: unknown[] = []; let paramIndex = 1; @@ -299,5 +370,12 @@ export async function taskUpdate(args: TaskUpdateArgs): Promise { return `Task not found: ${id}`; } + // Record activity + if (status && status !== task.status) { + await recordActivity(id, 'status_change', task.status, status); + } else { + await recordActivity(id, 'updated'); + } + return `Updated: ${id}`; } diff --git a/src/tools/index.ts b/src/tools/index.ts index a20f788..6a6a1b9 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -209,6 +209,86 @@ export const toolDefinitions = [ }, }, + // Version Tools + { + name: 'version_add', + description: 'Create a new version/release for a project', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Project key (e.g., VPN, ST)' }, + version: { type: 'string', description: 'Version number (e.g., 1.0.0, 2.1.0-beta)' }, + build_number: { type: 'number', description: 'Optional build number' }, + status: { type: 'string', enum: ['planned', 'in_progress', 'released', 'archived'], description: 'Version status (default: planned)' }, + release_notes: { type: 'string', description: 'Optional release notes' }, + }, + required: ['project', 'version'], + }, + }, + { + name: 'version_list', + description: 'List versions with optional filters', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Filter by project key' }, + status: { type: 'string', enum: ['planned', 'in_progress', 'released', 'archived'], description: 'Filter by status' }, + limit: { type: 'number', description: 'Max results (default: 20)' }, + }, + }, + }, + { + name: 'version_show', + description: 'Show version details with assigned tasks and epics', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Version ID (e.g., VPN-v1.0.0)' }, + }, + required: ['id'], + }, + }, + { + name: 'version_update', + description: 'Update version fields (status, git_tag, git_sha, release_notes)', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Version ID to update' }, + status: { type: 'string', enum: ['planned', 'in_progress', 'released', 'archived'], description: 'New status' }, + git_tag: { type: 'string', description: 'Git tag name (e.g., v1.0.0)' }, + git_sha: { type: 'string', description: 'Git commit SHA for this version' }, + release_notes: { type: 'string', description: 'Release notes' }, + release_date: { type: 'string', description: 'Release date (ISO format)' }, + }, + required: ['id'], + }, + }, + { + name: 'version_release', + description: 'Mark a version as released (sets status and release_date)', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Version ID to release' }, + git_tag: { type: 'string', description: 'Optional git tag to associate' }, + }, + required: ['id'], + }, + }, + { + name: 'version_assign_task', + description: 'Assign a task to a version', + inputSchema: { + type: 'object', + properties: { + task_id: { type: 'string', description: 'Task ID to assign' }, + version_id: { type: 'string', description: 'Version ID to assign to' }, + }, + required: ['task_id', 'version_id'], + }, + }, + // Delegation Tools { name: 'task_delegations', @@ -234,6 +314,81 @@ export const toolDefinitions = [ }, }, + // Commit Tools + { + name: 'task_commit_add', + description: 'Link a git commit to a task (SHA reference only, Gitea MCP has full commit data)', + inputSchema: { + type: 'object', + properties: { + task_id: { type: 'string', description: 'Task ID (e.g., VPN-123)' }, + commit_sha: { type: 'string', description: 'Git commit SHA (full or short)' }, + repo: { type: 'string', description: 'Repository (e.g., christian/VPN)' }, + source: { type: 'string', enum: ['manual', 'parsed', 'pr_merge'], description: 'How the link was created (default: manual)' }, + }, + required: ['task_id', 'commit_sha', 'repo'], + }, + }, + { + name: 'task_commit_remove', + description: 'Remove a commit link from a task', + inputSchema: { + type: 'object', + properties: { + task_id: { type: 'string', description: 'Task ID' }, + commit_sha: { type: 'string', description: 'Commit SHA to unlink' }, + }, + required: ['task_id', 'commit_sha'], + }, + }, + { + name: 'task_commits_list', + description: 'List commits linked to a task', + inputSchema: { + type: 'object', + properties: { + task_id: { type: 'string', description: 'Task ID' }, + }, + required: ['task_id'], + }, + }, + { + name: 'task_link_commits', + description: 'Parse commit messages for task references and create links (batch operation)', + inputSchema: { + type: 'object', + properties: { + repo: { type: 'string', description: 'Repository (e.g., christian/VPN)' }, + commits: { + type: 'array', + description: 'Array of commits with sha and message', + items: { + type: 'object', + properties: { + sha: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['sha', 'message'], + }, + }, + dry_run: { type: 'boolean', description: 'Preview without creating links (default: false)' }, + }, + required: ['repo', 'commits'], + }, + }, + { + name: 'session_tasks', + description: 'List tasks worked on in a session (from task_activity tracking)', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string', description: 'Session ID (supports * wildcard, e.g., session_20260110_*)' }, + limit: { type: 'number', description: 'Max results (default: 20)' }, + }, + required: ['session_id'], + }, + }, + // Project Lock Tools { name: 'project_lock', diff --git a/src/tools/versions.ts b/src/tools/versions.ts new file mode 100644 index 0000000..8b2f186 --- /dev/null +++ b/src/tools/versions.ts @@ -0,0 +1,306 @@ +// Version management operations for task-mcp + +import { query, queryOne, execute, getProjectKey } from '../db.js'; +import type { Version, Task } from '../types.js'; + +interface VersionAddArgs { + project: string; + version: string; + build_number?: number; + status?: string; + release_notes?: string; +} + +interface VersionListArgs { + project?: string; + status?: string; + limit?: number; +} + +interface VersionUpdateArgs { + id: string; + status?: string; + git_tag?: string; + git_sha?: string; + release_notes?: string; + release_date?: string; +} + +/** + * Generate version ID from project and version number + */ +function generateVersionId(projectKey: string, version: string): string { + return `${projectKey}-v${version.replace(/^v/, '')}`; +} + +/** + * Create a new version + */ +export async function versionAdd(args: VersionAddArgs): Promise { + const { project, version, build_number, status = 'planned', release_notes } = args; + + // Get project key + const projectKey = await getProjectKey(project); + + // Generate version ID + const versionId = generateVersionId(projectKey, version); + + // Check if version already exists + const existing = await queryOne<{ id: string }>(`SELECT id FROM versions WHERE id = $1`, [versionId]); + if (existing) { + return `Version already exists: ${versionId}`; + } + + // Insert version + await execute( + `INSERT INTO versions (id, project, version, build_number, status, release_notes) + VALUES ($1, $2, $3, $4, $5, $6)`, + [versionId, projectKey, version, build_number || null, status, release_notes || null] + ); + + return `Created version: ${versionId}\n Version: ${version}\n Project: ${projectKey}\n Status: ${status}${build_number ? `\n Build: ${build_number}` : ''}`; +} + +/** + * List versions with filters + */ +export async function versionList(args: VersionListArgs): Promise { + const { project, status, limit = 20 } = args; + + let whereClause = 'WHERE 1=1'; + const params: unknown[] = []; + let paramIndex = 1; + + if (project) { + const projectKey = await getProjectKey(project); + whereClause += ` AND v.project = $${paramIndex++}`; + params.push(projectKey); + } + if (status) { + whereClause += ` AND v.status = $${paramIndex++}`; + params.push(status); + } + + params.push(limit); + + const versions = await query( + `SELECT v.id, v.version, v.status, v.project, v.build_number, v.git_tag, + to_char(v.release_date, 'YYYY-MM-DD') as release_date, + COUNT(t.id) as task_count, + COUNT(t.id) FILTER (WHERE t.status != 'completed') as open_count + FROM versions v + LEFT JOIN tasks t ON t.version_id = v.id + ${whereClause} + GROUP BY v.id, v.version, v.status, v.project, v.build_number, v.git_tag, v.release_date, v.created_at + ORDER BY + CASE v.status WHEN 'in_progress' THEN 0 WHEN 'planned' THEN 1 WHEN 'released' THEN 2 ELSE 3 END, + v.created_at DESC + LIMIT $${paramIndex}`, + params + ); + + if (versions.length === 0) { + return `No versions found${project ? ` for project ${project}` : ''}`; + } + + const lines = versions.map(v => { + const statusIcon = v.status === 'released' ? '[R]' : v.status === 'in_progress' ? '[>]' : v.status === 'archived' ? '[A]' : '[ ]'; + const progress = v.task_count > 0 ? ` (${v.task_count - v.open_count}/${v.task_count} tasks)` : ''; + const tag = v.git_tag ? ` [${v.git_tag}]` : ''; + const date = (v as unknown as { release_date: string }).release_date ? ` - ${(v as unknown as { release_date: string }).release_date}` : ''; + return `${statusIcon} ${v.id}: ${v.version}${tag}${progress}${date}`; + }); + + return `Versions${project ? ` (${project})` : ''}:\n\n${lines.join('\n')}`; +} + +/** + * Show version details with assigned tasks + */ +export async function versionShow(id: string): Promise { + const version = await queryOne( + `SELECT id, project, version, build_number, status, release_notes, git_tag, git_sha, + to_char(created_at, 'YYYY-MM-DD HH24:MI') as created, + to_char(release_date, 'YYYY-MM-DD') as released + FROM versions WHERE id = $1`, + [id] + ); + + if (!version) { + return `Version not found: ${id}`; + } + + let output = `# ${version.id}\n\n`; + output += `**Version:** ${version.version}\n`; + output += `**Project:** ${version.project}\n`; + output += `**Status:** ${version.status}\n`; + if (version.build_number) { + output += `**Build:** ${version.build_number}\n`; + } + if ((version as unknown as { git_tag: string }).git_tag) { + output += `**Git Tag:** ${(version as unknown as { git_tag: string }).git_tag}\n`; + } + if ((version as unknown as { git_sha: string }).git_sha) { + output += `**Git SHA:** ${(version as unknown as { git_sha: string }).git_sha}\n`; + } + output += `**Created:** ${version.created}\n`; + if (version.released) { + output += `**Released:** ${version.released}\n`; + } + + if (version.release_notes) { + output += `\n**Release Notes:**\n${version.release_notes}\n`; + } + + // Get tasks assigned to this version + const tasks = await query( + `SELECT id, title, status, priority, type + FROM tasks + WHERE version_id = $1 + ORDER BY + CASE status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END, + CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END`, + [id] + ); + + if (tasks.length > 0) { + const done = tasks.filter(t => t.status === 'completed').length; + output += `\n**Tasks:** (${done}/${tasks.length} done)\n`; + for (const t of tasks) { + const statusIcon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[>]' : t.status === 'blocked' ? '[!]' : '[ ]'; + output += ` ${statusIcon} ${t.priority} ${t.id}: ${t.title}\n`; + } + } else { + output += `\n**Tasks:** None assigned\n`; + } + + // Get epics targeting this version + const epics = await query<{ id: string; title: string; status: string }>( + `SELECT id, title, status FROM epics WHERE target_version_id = $1`, + [id] + ); + + if (epics.length > 0) { + output += `\n**Epics:**\n`; + for (const e of epics) { + const statusIcon = e.status === 'completed' ? '[x]' : e.status === 'in_progress' ? '[>]' : '[ ]'; + output += ` ${statusIcon} ${e.id}: ${e.title}\n`; + } + } + + return output; +} + +/** + * Update a version + */ +export async function versionUpdate(args: VersionUpdateArgs): Promise { + const { id, status, git_tag, git_sha, release_notes, release_date } = args; + + const updates: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (status) { + updates.push(`status = $${paramIndex++}`); + params.push(status); + } + if (git_tag !== undefined) { + updates.push(`git_tag = $${paramIndex++}`); + params.push(git_tag); + } + if (git_sha !== undefined) { + updates.push(`git_sha = $${paramIndex++}`); + params.push(git_sha); + } + if (release_notes !== undefined) { + updates.push(`release_notes = $${paramIndex++}`); + params.push(release_notes); + } + if (release_date) { + updates.push(`release_date = $${paramIndex++}`); + params.push(release_date); + } + + if (updates.length === 0) { + return 'No updates specified'; + } + + params.push(id); + + const result = await execute( + `UPDATE versions SET ${updates.join(', ')} WHERE id = $${paramIndex}`, + params + ); + + if (result === 0) { + return `Version not found: ${id}`; + } + + return `Updated: ${id}`; +} + +/** + * Mark a version as released + */ +export async function versionRelease(args: { id: string; git_tag?: string }): Promise { + const { id, git_tag } = args; + + // Verify version exists + const version = await queryOne<{ id: string; status: string; version: string }>( + `SELECT id, status, version FROM versions WHERE id = $1`, + [id] + ); + + if (!version) { + return `Version not found: ${id}`; + } + + if (version.status === 'released') { + return `Version already released: ${id}`; + } + + // Update version status + const updates = ['status = $1', 'release_date = NOW()']; + const params: unknown[] = ['released']; + let paramIndex = 2; + + if (git_tag) { + updates.push(`git_tag = $${paramIndex++}`); + params.push(git_tag); + } + + params.push(id); + + await execute( + `UPDATE versions SET ${updates.join(', ')} WHERE id = $${paramIndex}`, + params + ); + + return `Released: ${id} (${version.version})${git_tag ? ` tagged as ${git_tag}` : ''}`; +} + +/** + * Assign a task to a version + */ +export async function versionAssignTask(args: { task_id: string; version_id: string }): Promise { + const { task_id, version_id } = args; + + // Verify version exists + const version = await queryOne<{ id: string }>(`SELECT id FROM versions WHERE id = $1`, [version_id]); + if (!version) { + return `Version not found: ${version_id}`; + } + + // Update task + const result = await execute( + `UPDATE tasks SET version_id = $1, updated_at = NOW() WHERE id = $2`, + [version_id, task_id] + ); + + if (result === 0) { + return `Task not found: ${task_id}`; + } + + return `Assigned ${task_id} to version ${version_id}`; +} diff --git a/src/types.ts b/src/types.ts index 1b6894a..c2dd97e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,27 @@ export interface Version { status: 'planned' | 'in_progress' | 'released' | 'archived'; release_date?: Date; release_notes?: string; + git_tag?: string; + git_sha?: string; + created_at: Date; +} + +export interface TaskActivity { + id: number; + task_id: string; + session_id: string; + activity_type: 'created' | 'updated' | 'status_change' | 'closed'; + old_value?: string; + new_value?: string; + created_at: Date; +} + +export interface TaskCommit { + id: number; + task_id: string; + commit_sha: string; + repo: string; + source: 'manual' | 'parsed' | 'pr_merge'; created_at: Date; }