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 { 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
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 { 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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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';
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user