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:
306
src/tools/versions.ts
Normal file
306
src/tools/versions.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user