From ad13a26168e6b06f12231e0df3df14f45f42a867 Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Sun, 5 Apr 2026 08:00:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(CF-2885):=20Add=20Jira=20as=20timeline=20s?= =?UTF-8?q?ource=20=E2=80=94=20pulls=20issue=20history=20via=20REST=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends timeline() with Jira integration since sessions moved to Jira-only in CF-836 (2026-02-08) and the session-mcp tables are empty by design. Adds to services/jira.ts: - getIssueWithHistory(key): fetches issue with expand=changelog + comments - searchIssueKeys(jql): JQL search returning minimal issue keys - ADF → plain text extractor for comment bodies Timeline now yields Jira events: issue_created, field_change:{status,assignee, resolution,priority,labels}, comment. Events are time-filtered client-side against the since/until window. For Jira-key subjects, also searches for linked session-tracking issues and merges their events. Tested against CF-2872 (audit task) and CF-2885 (this ticket) — shows full lifecycle from creation through transitions to resolution. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/services/jira.ts | 150 ++++++++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 4 +- src/tools/timeline.ts | 132 +++++++++++++++++++++++++++++++++++-- 3 files changed, 278 insertions(+), 8 deletions(-) diff --git a/src/services/jira.ts b/src/services/jira.ts index fc836ea..00b0006 100644 --- a/src/services/jira.ts +++ b/src/services/jira.ts @@ -255,6 +255,156 @@ export async function updateIssueDescription(issueKey: string, description: stri } } +// ---------- Read operations (CF-2885 timeline tool) ---------- + +export interface JiraChangelogEntry { + ts: string; // ISO8601 + author: string; + field: string; // e.g. "status", "assignee", "labels" + from: string | null; + to: string | null; +} + +export interface JiraComment { + id: string; + ts: string; + author: string; + body: string; +} + +export interface JiraIssueHistory { + key: string; + summary: string; + status: string; + issueType: string; + created: string; + creator: string; + labels: string[]; + parent?: string; + linkedIssues: Array<{ key: string; type: string; direction: 'in' | 'out' }>; + changelog: JiraChangelogEntry[]; + comments: JiraComment[]; +} + +function adfToPlainText(adf: unknown): string { + // Minimal Atlassian Document Format → plain text extractor. + if (!adf || typeof adf !== 'object') return ''; + const node = adf as { type?: string; text?: string; content?: unknown[] }; + if (node.text) return node.text; + if (Array.isArray(node.content)) { + return node.content.map(c => adfToPlainText(c)).join(' ').trim(); + } + return ''; +} + +/** + * Fetch a Jira issue with full changelog and comments. Returns null on failure. + */ +export async function getIssueWithHistory(issueKey: string): Promise { + if (!isConfigured()) return null; + + try { + // Issue with changelog expansion + const issueResp = await jiraFetch(`/issue/${issueKey}?expand=changelog&fields=summary,status,issuetype,created,creator,labels,parent,issuelinks`); + if (!issueResp.ok) { + console.error(`session-mcp: Jira get issue failed (${issueResp.status}) for ${issueKey}`); + return null; + } + const issue = await issueResp.json() as any; + + // Comments (separate endpoint for full data) + const commentsResp = await jiraFetch(`/issue/${issueKey}/comment?orderBy=created`); + const commentsJson = commentsResp.ok ? await commentsResp.json() as any : { comments: [] }; + + // Parse changelog histories → flat entries + const changelog: JiraChangelogEntry[] = []; + const histories = issue.changelog?.histories || []; + for (const h of histories) { + const author = h.author?.displayName || h.author?.emailAddress || 'unknown'; + const ts = h.created; + for (const item of (h.items || [])) { + changelog.push({ + ts, + author, + field: item.field, + from: item.fromString || item.from || null, + to: item.toString || item.to || null, + }); + } + } + + // Parse comments + const comments: JiraComment[] = (commentsJson.comments || []).map((c: any) => ({ + id: c.id, + ts: c.created, + author: c.author?.displayName || c.author?.emailAddress || 'unknown', + body: adfToPlainText(c.body), + })); + + // Parse linked issues + const linkedIssues: Array<{ key: string; type: string; direction: 'in' | 'out' }> = []; + for (const link of (issue.fields?.issuelinks || [])) { + if (link.outwardIssue) { + linkedIssues.push({ + key: link.outwardIssue.key, + type: link.type?.outward || 'relates to', + direction: 'out', + }); + } else if (link.inwardIssue) { + linkedIssues.push({ + key: link.inwardIssue.key, + type: link.type?.inward || 'relates to', + direction: 'in', + }); + } + } + + return { + key: issue.key, + summary: issue.fields?.summary || '', + status: issue.fields?.status?.name || '', + issueType: issue.fields?.issuetype?.name || '', + created: issue.fields?.created || '', + creator: issue.fields?.creator?.displayName || issue.fields?.creator?.emailAddress || 'unknown', + labels: issue.fields?.labels || [], + parent: issue.fields?.parent?.key, + linkedIssues, + changelog, + comments, + }; + } catch (err) { + console.error('session-mcp: Jira get issue history error:', err); + return null; + } +} + +/** + * Search for issues via JQL. Returns array of issue keys (minimal projection). + */ +export async function searchIssueKeys(jql: string, limit: number = 50): Promise { + if (!isConfigured()) return []; + + try { + const params = new URLSearchParams({ + jql, + fields: 'summary', + maxResults: String(limit), + }); + const response = await jiraFetch(`/search/jql?${params.toString()}`); + if (!response.ok) { + console.error(`session-mcp: Jira search failed (${response.status})`); + return []; + } + const data = await response.json() as any; + return (data.issues || []).map((i: any) => i.key); + } catch (err) { + console.error('session-mcp: Jira search error:', err); + return []; + } +} + +// ---------- Write operations ---------- + /** * Link two Jira issues. */ diff --git a/src/tools/index.ts b/src/tools/index.ts index 7bdb44b..53cc042 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -156,8 +156,8 @@ export const toolDefinitions = [ until: { type: 'string', description: 'ISO8601 timestamp (default: now)' }, sources: { type: 'array', - items: { type: 'string', enum: ['session', 'note', 'commit', 'plan', 'task_commit'] }, - description: 'Optional filter: which event sources to include (default: all)', + items: { type: 'string', enum: ['session', 'note', 'commit', 'plan', 'task_commit', 'jira'] }, + description: 'Optional filter: which event sources to include (default: all). Jira source pulls issue history (transitions, comments, field changes) via the AgilitonAPI gateway.', }, limit: { type: 'number', description: 'Max events to return (default: 100)' }, }, diff --git a/src/tools/timeline.ts b/src/tools/timeline.ts index d50392e..45d2ee5 100644 --- a/src/tools/timeline.ts +++ b/src/tools/timeline.ts @@ -1,10 +1,11 @@ // CF-2885: Event Timeline — unified chronological view across session-mcp sources -// Stitches sessions, notes, commits, plans, and task-commit links into a single -// time-ordered event stream for LLM consumption. +// Stitches sessions, notes, commits, plans, task-commit links, and Jira history +// into a single time-ordered event stream for LLM consumption. import { query } from '../db.js'; +import { getIssueWithHistory, searchIssueKeys } from '../services/jira.js'; -export type EventSource = 'session' | 'note' | 'commit' | 'plan' | 'task_commit'; +export type EventSource = 'session' | 'note' | 'commit' | 'plan' | 'task_commit' | 'jira'; export interface TimelineEvent { ts: string; // ISO8601 @@ -345,6 +346,107 @@ async function fetchPlanEvents( return events; } +// ---------- Jira source ---------- + +function filterByTimeWindow(events: T[], sinceISO: string, untilISO: string): T[] { + const sinceMs = new Date(sinceISO).getTime(); + const untilMs = new Date(untilISO).getTime(); + return events.filter(e => { + const t = new Date(e.ts).getTime(); + return t >= sinceMs && t <= untilMs; + }); +} + +// Resolve relative "since" into ISO8601 for client-side filtering of Jira data +function resolveSinceISO(since?: string): string { + if (!since) return new Date(Date.now() - 7 * 86400_000).toISOString(); + const rel = since.match(/^-(\d+)([dhm])$/); + if (rel) { + const n = parseInt(rel[1]); + const ms = rel[2] === 'd' ? n * 86400_000 : rel[2] === 'h' ? n * 3600_000 : n * 60_000; + return new Date(Date.now() - ms).toISOString(); + } + return since; +} + +function resolveUntilISO(until?: string): string { + return until || new Date().toISOString(); +} + +async function fetchJiraEvents( + subject: string, + kind: SubjectKind, + sinceISO: string, + untilISO: string +): Promise { + if (kind !== 'jira') return []; + + const issue = await getIssueWithHistory(subject); + if (!issue) return []; + + const events: TimelineEvent[] = []; + + // Creation event + if (issue.created) { + events.push({ + ts: issue.created, + source: 'jira', + type: 'issue_created', + subject: issue.key, + summary: `${issue.issueType} created: ${issue.summary}`, + details: { labels: issue.labels, creator: issue.creator }, + links: { jira: issue.key }, + }); + } + + // Changelog entries (field changes) + for (const ch of issue.changelog) { + // Prioritize status transitions, assignee changes, resolution changes + const notable = new Set(['status', 'assignee', 'resolution', 'priority', 'labels']); + if (!notable.has(ch.field)) continue; + const fromStr = ch.from ?? '∅'; + const toStr = ch.to ?? '∅'; + events.push({ + ts: ch.ts, + source: 'jira', + type: `field_change:${ch.field}`, + subject: issue.key, + summary: `${ch.field}: ${fromStr} → ${toStr} by ${ch.author}`, + details: { field: ch.field, from: ch.from, to: ch.to, author: ch.author }, + links: { jira: issue.key }, + }); + } + + // Comments + for (const c of issue.comments) { + events.push({ + ts: c.ts, + source: 'jira', + type: 'comment', + subject: issue.key, + summary: `💬 ${c.author}: ${truncate(c.body, 120)}`, + details: { author: c.author, body: c.body }, + links: { jira: issue.key }, + }); + } + + return filterByTimeWindow(events, sinceISO, untilISO); +} + +async function fetchLinkedSessionIssueKeys(subject: string, kind: SubjectKind): Promise { + // For a task issue, find session-tracking issues that relate to it + if (kind !== 'jira') return []; + const jql = `labels = "session-tracking" AND issueFunction in linkedIssuesOf("key = ${subject}")`; + // Fallback: simpler query that works without Script Runner + const simpleJql = `labels = "session-tracking" AND text ~ "${subject}"`; + try { + const keys = await searchIssueKeys(simpleJql, 20); + return keys; + } catch { + return []; + } +} + // ---------- main tool ---------- export async function timeline(args: TimelineArgs): Promise { @@ -360,19 +462,36 @@ export async function timeline(args: TimelineArgs): Promise { const since = resolveSince(args.since); const until = resolveUntil(args.until); + const sinceISO = resolveSinceISO(args.since); + const untilISO = resolveUntilISO(args.until); const limit = args.limit ?? 100; - const sourceFilter = new Set(args.sources ?? ['session', 'note', 'commit', 'plan', 'task_commit']); + const sourceFilter = new Set( + args.sources ?? ['session', 'note', 'commit', 'plan', 'task_commit', 'jira'] + ); + + // For Jira subjects, also fetch any linked session-tracking issues and include their history + const jiraTargets: string[] = []; + if (kind === 'jira' && sourceFilter.has('jira')) { + jiraTargets.push(subject); + // Also pull linked session-tracking issues (if this is a task, its session issues) + const linked = await fetchLinkedSessionIssueKeys(subject, kind); + for (const k of linked) { + if (k !== subject) jiraTargets.push(k); + } + } // Run all queries in parallel - const [sessionEvents, noteEvents, commitEvents, planEvents, taskCommitEvents] = await Promise.all([ + const [sessionEvents, noteEvents, commitEvents, planEvents, taskCommitEvents, ...jiraEventArrays] = await Promise.all([ sourceFilter.has('session') ? fetchSessionEvents(subject, kind, since, until) : Promise.resolve([]), sourceFilter.has('note') ? fetchNoteEvents(subject, kind, since, until) : Promise.resolve([]), sourceFilter.has('commit') ? fetchCommitEvents(subject, kind, since, until) : Promise.resolve([]), sourceFilter.has('plan') ? fetchPlanEvents(subject, kind, since, until) : Promise.resolve([]), sourceFilter.has('task_commit') ? fetchTaskCommitEvents(subject, kind, since, until) : Promise.resolve([]), + ...jiraTargets.map(k => fetchJiraEvents(k, 'jira', sinceISO, untilISO)), ]); - const all = [...sessionEvents, ...noteEvents, ...commitEvents, ...planEvents, ...taskCommitEvents]; + const jiraEvents = jiraEventArrays.flat(); + const all = [...sessionEvents, ...noteEvents, ...commitEvents, ...planEvents, ...taskCommitEvents, ...jiraEvents]; // Sort chronologically (oldest → newest by default for narrative reading) all.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()); @@ -402,6 +521,7 @@ export async function timeline(args: TimelineArgs): Promise { commit: '🔨', plan: '📋', task_commit: '🔗', + jira: '🎫', }[e.source]; output += ` \`${timeStr}\` ${icon} \`${e.type}\` ${e.summary}\n`; }