feat(CF-762): Add Jira integration for session tracking

Sessions now auto-create CF Jira issues on start and post full session
output as comments on end, transitioning the issue to Done.

- Add src/services/jira.ts with createSessionIssue, addComment, transitionToDone
- Update session_start to create CF Jira issue and store key in sessions table
- Update session_end to post session output and close Jira issue
- Add migration 031 to archive local task tables (moved to Jira Cloud)
- Update .env.example with Jira Cloud env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-02-08 07:23:18 +02:00
parent 1227e5b339
commit 63cba97b56
4 changed files with 462 additions and 8 deletions

View File

@@ -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

View File

@@ -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;

277
src/services/jira.ts Normal file
View File

@@ -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<Response> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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;
}
}

View File

@@ -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<string> {
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<string> {
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<string> {
}
/**
* 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<string> {
const { session_id, summary, status = 'completed' } = args;
@@ -156,8 +185,8 @@ export async function sessionEnd(args: SessionEndArgs): Promise<string> {
}
// Get session details
const session = await queryOne<Session>(
`SELECT id, project, session_number, duration_minutes
const session = await queryOne<Session & { jira_issue_key: string | null }>(
`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<string> {
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<string> {
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<string, string[]> = {};
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');
}
/**