Files
session-mcp/src/tools/versions.ts
Christian Gick 5015b1416f 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>
2026-01-10 10:28:21 +02:00

307 lines
9.0 KiB
TypeScript

// 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}`;
}