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:
@@ -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
|
||||
|
||||
50
migrations/031_archive_task_tables.sql
Normal file
50
migrations/031_archive_task_tables.sql
Normal 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
277
src/services/jira.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user