diff --git a/.env.example b/.env.example index 0fa22ac..b425d21 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Task MCP Environment Variables +# Session MCP Environment Variables (forked from task-mcp, CF-762) # PostgreSQL connection via pgbouncer POSTGRES_HOST=postgres.agiliton.internal @@ -7,3 +7,8 @@ POSTGRES_PORT=6432 # Embedding service configuration LLM_API_URL=https://api.agiliton.cloud/llm LLM_API_KEY=your_llm_api_key_here + +# Jira Cloud (session tracking) +JIRA_URL=https://agiliton.atlassian.net +JIRA_USERNAME=your_email@agiliton.eu +JIRA_API_TOKEN=your_jira_api_token diff --git a/migrations/031_archive_task_tables.sql b/migrations/031_archive_task_tables.sql new file mode 100644 index 0000000..d6e372e --- /dev/null +++ b/migrations/031_archive_task_tables.sql @@ -0,0 +1,50 @@ +-- Migration 031: Archive task tables after Jira Cloud migration (CF-762) +-- Task management moved to Jira Cloud. Archive local task tables for historical reference. +-- Session, memory, archive, and infrastructure tables remain active. + +BEGIN; + +-- 1. Archive task tables (rename with archived_ prefix) +ALTER TABLE IF EXISTS tasks RENAME TO archived_tasks; +ALTER TABLE IF EXISTS task_checklist RENAME TO archived_task_checklist; +ALTER TABLE IF EXISTS task_links RENAME TO archived_task_links; +ALTER TABLE IF EXISTS task_activity RENAME TO archived_task_activity; +ALTER TABLE IF EXISTS task_sequences RENAME TO archived_task_sequences; + +-- 2. Add archived_at timestamp to archived tables +ALTER TABLE IF EXISTS archived_tasks ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(); +ALTER TABLE IF EXISTS archived_task_checklist ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(); +ALTER TABLE IF EXISTS archived_task_links ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(); +ALTER TABLE IF EXISTS archived_task_activity ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(); +ALTER TABLE IF EXISTS archived_task_sequences ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(); + +-- 3. Drop tables that are fully replaced by Jira (data already migrated) +DROP TABLE IF EXISTS epics CASCADE; +DROP TABLE IF EXISTS epic_sequences CASCADE; +DROP TABLE IF EXISTS versions CASCADE; + +-- 4. Keep these tables (still referenced by session tools): +-- - task_commits (git commit ↔ Jira issue linking) +-- - task_migration_map (maps old local IDs → Jira keys) +-- - task_delegations (code delegation tracking) + +-- 5. Update task_commits to remove FK constraint on archived_tasks +-- (commits now reference Jira issue keys, not local task IDs) +ALTER TABLE IF EXISTS task_commits DROP CONSTRAINT IF EXISTS task_commits_task_id_fkey; + +-- 6. Update task_delegations to remove FK constraint on archived_tasks +ALTER TABLE IF EXISTS task_delegations DROP CONSTRAINT IF EXISTS task_delegations_task_id_fkey; + +-- 7. Drop unused indexes on archived tables (save space, they're read-only now) +DROP INDEX IF EXISTS idx_tasks_status; +DROP INDEX IF EXISTS idx_tasks_type; +DROP INDEX IF EXISTS idx_tasks_priority; +DROP INDEX IF EXISTS idx_tasks_epic; +DROP INDEX IF EXISTS idx_tasks_version; +DROP INDEX IF EXISTS idx_tasks_embedding; + +-- 8. Record migration +INSERT INTO schema_migrations (version, applied_at) VALUES ('031_archive_task_tables', NOW()) +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/src/services/jira.ts b/src/services/jira.ts new file mode 100644 index 0000000..b84dd2e --- /dev/null +++ b/src/services/jira.ts @@ -0,0 +1,277 @@ +/** + * Jira Cloud REST API client for session-mcp. + * Creates/closes CF issues for sessions and posts session output as comments. + * + * Uses JIRA_URL, JIRA_USERNAME, JIRA_API_TOKEN env vars. + */ + +interface JiraIssue { + key: string; + id: string; + self: string; +} + +interface JiraTransition { + id: string; + name: string; +} + +const getConfig = () => ({ + url: process.env.JIRA_URL || 'https://agiliton.atlassian.net', + username: process.env.JIRA_USERNAME || '', + token: process.env.JIRA_API_TOKEN || '', +}); + +function getAuthHeader(): string { + const { username, token } = getConfig(); + return `Basic ${Buffer.from(`${username}:${token}`).toString('base64')}`; +} + +function isConfigured(): boolean { + const { username, token } = getConfig(); + return !!(username && token); +} + +async function jiraFetch(path: string, options: RequestInit = {}): Promise { + const { url } = getConfig(); + const fullUrl = `${url}/rest/api/3${path}`; + + return fetch(fullUrl, { + ...options, + headers: { + 'Authorization': getAuthHeader(), + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...options.headers, + }, + }); +} + +/** + * Create a Jira issue in the CF project for session tracking. + */ +export async function createSessionIssue(params: { + sessionNumber: number | null; + project: string; + parentIssueKey?: string; + branch?: string; + workingDirectory?: string; +}): Promise<{ key: string } | null> { + if (!isConfigured()) { + console.error('session-mcp: Jira not configured, skipping issue creation'); + return null; + } + + const { sessionNumber, project, parentIssueKey, branch, workingDirectory } = params; + const sessionLabel = sessionNumber ? `#${sessionNumber}` : 'new'; + + const summary = `Session ${sessionLabel}: ${project}${parentIssueKey ? ` - ${parentIssueKey}` : ''}`; + + const descriptionParts = [ + `Automated session tracking issue.`, + `Project: ${project}`, + branch ? `Branch: ${branch}` : null, + workingDirectory ? `Working directory: ${workingDirectory}` : null, + parentIssueKey ? `Parent task: ${parentIssueKey}` : null, + `Started: ${new Date().toISOString()}`, + ].filter(Boolean); + + try { + const response = await jiraFetch('/issue', { + method: 'POST', + body: JSON.stringify({ + fields: { + project: { key: 'CF' }, + summary, + description: { + type: 'doc', + version: 1, + content: [{ + type: 'paragraph', + content: [{ + type: 'text', + text: descriptionParts.join('\n'), + }], + }], + }, + issuetype: { name: 'Task' }, + labels: ['session-tracking', `project-${project.toLowerCase()}`], + }, + }), + }); + + if (!response.ok) { + const body = await response.text(); + console.error(`session-mcp: Jira create issue failed (${response.status}): ${body}`); + return null; + } + + const issue = await response.json() as JiraIssue; + + // Link to parent issue if provided + if (parentIssueKey) { + await linkIssues(issue.key, parentIssueKey, 'relates to'); + } + + return { key: issue.key }; + } catch (err) { + console.error('session-mcp: Jira create issue error:', err); + return null; + } +} + +/** + * Add a comment to a Jira issue (used for session output). + */ +export async function addComment(issueKey: string, markdownBody: string): Promise { + if (!isConfigured()) return false; + + try { + const response = await jiraFetch(`/issue/${issueKey}/comment`, { + method: 'POST', + body: JSON.stringify({ + body: { + type: 'doc', + version: 1, + content: [{ + type: 'codeBlock', + attrs: { language: 'markdown' }, + content: [{ + type: 'text', + text: markdownBody, + }], + }], + }, + }), + }); + + if (!response.ok) { + const body = await response.text(); + console.error(`session-mcp: Jira add comment failed (${response.status}): ${body}`); + return false; + } + + return true; + } catch (err) { + console.error('session-mcp: Jira add comment error:', err); + return false; + } +} + +/** + * Transition a Jira issue to "Done" status. + */ +export async function transitionToDone(issueKey: string): Promise { + if (!isConfigured()) return false; + + try { + // Get available transitions + const transResponse = await jiraFetch(`/issue/${issueKey}/transitions`); + if (!transResponse.ok) { + console.error(`session-mcp: Jira get transitions failed (${transResponse.status})`); + return false; + } + + const { transitions } = await transResponse.json() as { transitions: JiraTransition[] }; + const doneTrans = transitions.find( + t => t.name.toLowerCase() === 'done' || t.name.toLowerCase() === 'resolve' + ); + + if (!doneTrans) { + console.error(`session-mcp: No "Done" transition found for ${issueKey}. Available: ${transitions.map(t => t.name).join(', ')}`); + return false; + } + + // Execute transition + const response = await jiraFetch(`/issue/${issueKey}/transitions`, { + method: 'POST', + body: JSON.stringify({ + transition: { id: doneTrans.id }, + }), + }); + + if (!response.ok) { + const body = await response.text(); + console.error(`session-mcp: Jira transition failed (${response.status}): ${body}`); + return false; + } + + return true; + } catch (err) { + console.error('session-mcp: Jira transition error:', err); + return false; + } +} + +/** + * Update a Jira issue description (used for final session summary). + */ +export async function updateIssueDescription(issueKey: string, description: string): Promise { + if (!isConfigured()) return false; + + try { + const response = await jiraFetch(`/issue/${issueKey}`, { + method: 'PUT', + body: JSON.stringify({ + fields: { + description: { + type: 'doc', + version: 1, + content: [{ + type: 'codeBlock', + attrs: { language: 'markdown' }, + content: [{ + type: 'text', + text: description, + }], + }], + }, + }, + }), + }); + + if (!response.ok) { + const body = await response.text(); + console.error(`session-mcp: Jira update description failed (${response.status}): ${body}`); + return false; + } + + return true; + } catch (err) { + console.error('session-mcp: Jira update description error:', err); + return false; + } +} + +/** + * Link two Jira issues. + */ +export async function linkIssues( + inwardKey: string, + outwardKey: string, + linkType: string = 'relates to' +): Promise { + if (!isConfigured()) return false; + + try { + const response = await jiraFetch('/issueLink', { + method: 'POST', + body: JSON.stringify({ + type: { name: linkType }, + inwardIssue: { key: inwardKey }, + outwardIssue: { key: outwardKey }, + }), + }); + + if (!response.ok) { + const body = await response.text(); + console.error(`session-mcp: Jira link issues failed (${response.status}): ${body}`); + return false; + } + + return true; + } catch (err) { + console.error('session-mcp: Jira link issues error:', err); + return false; + } +} diff --git a/src/tools/sessions.ts b/src/tools/sessions.ts index 1b39b14..43abe32 100644 --- a/src/tools/sessions.ts +++ b/src/tools/sessions.ts @@ -1,7 +1,9 @@ // Session management operations for database-driven session tracking +// Sessions auto-create CF Jira issues and post output on close (CF-762) import { query, queryOne, execute } from '../db.js'; import { getEmbedding, formatEmbedding } from '../embeddings.js'; +import { createSessionIssue, addComment, transitionToDone, updateIssueDescription } from '../services/jira.js'; interface SessionStartArgs { session_id?: string; @@ -58,8 +60,9 @@ interface Session { } /** - * Start a new session with metadata tracking - * Returns session_id and session_number + * Start a new session with metadata tracking. + * Auto-creates a CF Jira issue for session tracking. + * Returns session_id, session_number, and Jira issue key. */ export async function sessionStart(args: SessionStartArgs): Promise { const { session_id, project, working_directory, git_branch, initial_prompt, jira_issue_key } = args; @@ -81,7 +84,32 @@ export async function sessionStart(args: SessionStartArgs): Promise { const session_number = result?.session_number || null; - return `Session started: ${id} (${project} #${session_number})`; + // Auto-create CF Jira issue for session tracking (non-blocking) + let sessionJiraKey: string | null = jira_issue_key || null; + if (!sessionJiraKey) { + try { + const jiraResult = await createSessionIssue({ + sessionNumber: session_number, + project, + parentIssueKey: jira_issue_key || undefined, + branch: git_branch || undefined, + workingDirectory: working_directory || undefined, + }); + if (jiraResult) { + sessionJiraKey = jiraResult.key; + // Store the auto-created Jira issue key + await execute( + `UPDATE sessions SET jira_issue_key = $1 WHERE id = $2`, + [sessionJiraKey, id] + ); + } + } catch (err) { + console.error('session-mcp: Failed to create session Jira issue:', err); + } + } + + const jiraInfo = sessionJiraKey ? ` [${sessionJiraKey}]` : ''; + return `Session started: ${id} (${project} #${session_number})${jiraInfo}`; } /** @@ -123,7 +151,8 @@ export async function sessionUpdate(args: SessionUpdateArgs): Promise { } /** - * End session and generate summary with embedding + * End session and generate summary with embedding. + * Posts full session output as Jira comment and transitions session issue to Done. */ export async function sessionEnd(args: SessionEndArgs): Promise { const { session_id, summary, status = 'completed' } = args; @@ -156,8 +185,8 @@ export async function sessionEnd(args: SessionEndArgs): Promise { } // Get session details - const session = await queryOne( - `SELECT id, project, session_number, duration_minutes + const session = await queryOne( + `SELECT id, project, session_number, duration_minutes, jira_issue_key FROM sessions WHERE id = $1`, [session_id] ); @@ -166,7 +195,100 @@ export async function sessionEnd(args: SessionEndArgs): Promise { return `Session ended: ${session_id}`; } - return `Session ended: ${session.project} #${session.session_number} (${session.duration_minutes || 0}m)`; + // Post session output to Jira and close the session issue (non-blocking) + let jiraStatus = ''; + if (session.jira_issue_key) { + try { + // Collect session output for Jira comment + const sessionOutput = await buildSessionOutput(session_id, session, summary); + + // Post as comment + const commented = await addComment(session.jira_issue_key, sessionOutput); + + // Update issue description with final summary + const descriptionUpdate = [ + `## Session ${session.project} #${session.session_number}`, + `**Duration:** ${session.duration_minutes || 0} minutes`, + `**Status:** ${status}`, + `**Session ID:** ${session_id}`, + '', + `## Summary`, + summary, + ].join('\n'); + await updateIssueDescription(session.jira_issue_key, descriptionUpdate); + + // Transition to Done + const transitioned = await transitionToDone(session.jira_issue_key); + + jiraStatus = commented && transitioned + ? ` [${session.jira_issue_key} → Done]` + : commented + ? ` [${session.jira_issue_key} commented]` + : ` [${session.jira_issue_key} Jira update partial]`; + } catch (err) { + console.error('session-mcp: Failed to update session Jira issue:', err); + jiraStatus = ` [${session.jira_issue_key} Jira update failed]`; + } + } + + return `Session ended: ${session.project} #${session.session_number} (${session.duration_minutes || 0}m)${jiraStatus}`; +} + +/** + * Build full session output markdown for Jira comment. + */ +async function buildSessionOutput( + session_id: string, + session: { project: string | null; session_number: number | null; duration_minutes: number | null }, + summary: string +): Promise { + const lines: string[] = []; + lines.push(`# Session ${session.project} #${session.session_number}`); + lines.push(`Duration: ${session.duration_minutes || 0} minutes`); + lines.push(''); + lines.push(`## Summary`); + lines.push(summary); + lines.push(''); + + // Get session notes + const notes = await query<{ note_type: string; content: string }>( + `SELECT note_type, content FROM session_notes WHERE session_id = $1 ORDER BY created_at`, + [session_id] + ); + + if (notes.length > 0) { + const grouped: Record = {}; + for (const n of notes) { + if (!grouped[n.note_type]) grouped[n.note_type] = []; + grouped[n.note_type].push(n.content); + } + + for (const [type, items] of Object.entries(grouped)) { + const label = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + lines.push(`## ${label}`); + for (const item of items) { + lines.push(`- ${item}`); + } + lines.push(''); + } + } + + // Get commits + const commits = await query<{ commit_sha: string; repo: string; commit_message: string | null }>( + `SELECT commit_sha, repo, commit_message FROM session_commits WHERE session_id = $1 ORDER BY committed_at DESC`, + [session_id] + ); + + if (commits.length > 0) { + lines.push(`## Commits (${commits.length})`); + for (const c of commits) { + const msg = c.commit_message ? c.commit_message.split('\n')[0] : 'No message'; + lines.push(`- ${c.commit_sha.substring(0, 7)} (${c.repo}): ${msg}`); + } + lines.push(''); + } + + return lines.join('\n'); } /**