feat: Add version management, session tracking, and commit linking

Phase 1: Version Management
- Migration 006: Add git_tag, git_sha columns to versions
- New tools: version_add, version_list, version_show, version_update,
  version_release, version_assign_task
- Links versions to git tags for release tracking

Phase 2: Session/Task Activity Tracking
- Migration 007: Create task_activity table
- Track task changes per session (created, updated, status_change, closed)
- Session ID from env/cache file (usage-stats format)
- New tool: session_tasks to query tasks by session

Phase 3: Commit-Task Linking
- Migration 008: Create task_commits junction table
- New tools: task_commit_add, task_commit_remove, task_commits_list,
  task_link_commits (batch parsing)
- Stores SHA references only (Gitea MCP owns full data)
- Commits shown in task_show output

17 new MCP tools total. All migrations applied to docker-host postgres.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-01-10 10:28:21 +02:00
parent e12cccf718
commit 5015b1416f
9 changed files with 930 additions and 0 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -24,6 +24,8 @@ import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from '.
import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js'; import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js';
import { taskDelegations, taskDelegationQuery } from './tools/delegations.js'; import { taskDelegations, taskDelegationQuery } from './tools/delegations.js';
import { projectLock, projectUnlock, projectLockStatus, projectContext } from './tools/locks.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 // Create MCP server
const server = new Server( const server = new Server(
@@ -189,6 +191,81 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
result = await projectContext(); result = await projectContext();
break; 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: default:
throw new Error(`Unknown tool: ${name}`); throw new Error(`Unknown tool: ${name}`);
} }

255
src/tools/commits.ts Normal file
View File

@@ -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<string> {
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<string> {
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<string> {
const commits = await query<TaskCommit>(
`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<string> {
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<string | null> {
const commits = await query<TaskCommit & { created: string }>(
`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<string> {
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')}`;
}

View File

@@ -4,6 +4,56 @@ import { query, queryOne, execute, getNextTaskId, getProjectKey } from '../db.js
import { getEmbedding, formatEmbedding } from '../embeddings.js'; import { getEmbedding, formatEmbedding } from '../embeddings.js';
import type { Task, ChecklistItem, TaskLink } from '../types.js'; import type { Task, ChecklistItem, TaskLink } from '../types.js';
import { getRecentDelegations } from './delegations.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<void> {
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 { interface TaskAddArgs {
title: string; title: string;
@@ -61,6 +111,9 @@ export async function taskAdd(args: TaskAddArgs): Promise<string> {
); );
} }
// 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)' : ''}`; 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<string> {
} }
} }
// Get commits
const commitHistory = await getTaskCommits(id);
if (commitHistory) {
output += commitHistory;
}
// Get recent delegations // Get recent delegations
const delegationHistory = await getRecentDelegations(id); const delegationHistory = await getRecentDelegations(id);
if (delegationHistory) { if (delegationHistory) {
@@ -239,6 +298,9 @@ export async function taskShow(id: string): Promise<string> {
* Close a task * Close a task
*/ */
export async function taskClose(id: string): Promise<string> { export async function taskClose(id: string): Promise<string> {
// Get current status for activity tracking
const task = await queryOne<{ status: string }>(`SELECT status FROM tasks WHERE id = $1`, [id]);
const result = await execute( const result = await execute(
`UPDATE tasks `UPDATE tasks
SET status = 'completed', completed_at = NOW(), updated_at = NOW() SET status = 'completed', completed_at = NOW(), updated_at = NOW()
@@ -250,6 +312,9 @@ export async function taskClose(id: string): Promise<string> {
return `Task not found: ${id}`; return `Task not found: ${id}`;
} }
// Record activity
await recordActivity(id, 'closed', task?.status, 'completed');
return `Closed: ${id}`; return `Closed: ${id}`;
} }
@@ -259,6 +324,12 @@ export async function taskClose(id: string): Promise<string> {
export async function taskUpdate(args: TaskUpdateArgs): Promise<string> { export async function taskUpdate(args: TaskUpdateArgs): Promise<string> {
const { id, status, priority, type, title } = args; 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 updates: string[] = [];
const params: unknown[] = []; const params: unknown[] = [];
let paramIndex = 1; let paramIndex = 1;
@@ -299,5 +370,12 @@ export async function taskUpdate(args: TaskUpdateArgs): Promise<string> {
return `Task not found: ${id}`; 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}`; return `Updated: ${id}`;
} }

View File

@@ -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 // Delegation Tools
{ {
name: 'task_delegations', 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 // Project Lock Tools
{ {
name: 'project_lock', name: 'project_lock',

306
src/tools/versions.ts Normal file
View File

@@ -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<string> {
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<string> {
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<Version & { task_count: number; open_count: number }>(
`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<string> {
const version = await queryOne<Version & { created: string; released: string }>(
`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<Task>(
`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<string> {
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<string> {
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<string> {
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}`;
}

View File

@@ -48,6 +48,27 @@ export interface Version {
status: 'planned' | 'in_progress' | 'released' | 'archived'; status: 'planned' | 'in_progress' | 'released' | 'archived';
release_date?: Date; release_date?: Date;
release_notes?: string; 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; created_at: Date;
} }