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:
6
migrations/006_version_git_tag.sql
Normal file
6
migrations/006_version_git_tag.sql
Normal 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);
|
||||
16
migrations/007_task_activity.sql
Normal file
16
migrations/007_task_activity.sql
Normal 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);
|
||||
16
migrations/008_task_commits.sql
Normal file
16
migrations/008_task_commits.sql
Normal 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);
|
||||
77
src/index.ts
77
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}`);
|
||||
}
|
||||
|
||||
255
src/tools/commits.ts
Normal file
255
src/tools/commits.ts
Normal 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')}`;
|
||||
}
|
||||
@@ -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<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 {
|
||||
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)' : ''}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
const delegationHistory = await getRecentDelegations(id);
|
||||
if (delegationHistory) {
|
||||
@@ -239,6 +298,9 @@ export async function taskShow(id: string): Promise<string> {
|
||||
* Close a task
|
||||
*/
|
||||
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(
|
||||
`UPDATE tasks
|
||||
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}`;
|
||||
}
|
||||
|
||||
// Record activity
|
||||
await recordActivity(id, 'closed', task?.status, 'completed');
|
||||
|
||||
return `Closed: ${id}`;
|
||||
}
|
||||
|
||||
@@ -259,6 +324,12 @@ export async function taskClose(id: string): Promise<string> {
|
||||
export async function taskUpdate(args: TaskUpdateArgs): Promise<string> {
|
||||
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<string> {
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
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}`;
|
||||
}
|
||||
21
src/types.ts
21
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user