feat(CF-2885): Add Jira as timeline source — pulls issue history via REST API

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) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-04-05 08:00:25 +03:00
parent 2ed6e68686
commit ad13a26168
3 changed files with 278 additions and 8 deletions

View File

@@ -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<JiraIssueHistory | null> {
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<string[]> {
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.
*/

View File

@@ -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)' },
},

View File

@@ -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<T extends { ts: string }>(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<TimelineEvent[]> {
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<string[]> {
// 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<string> {
@@ -360,19 +462,36 @@ export async function timeline(args: TimelineArgs): Promise<string> {
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<EventSource>(args.sources ?? ['session', 'note', 'commit', 'plan', 'task_commit']);
const sourceFilter = new Set<EventSource>(
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<string> {
commit: '🔨',
plan: '📋',
task_commit: '🔗',
jira: '🎫',
}[e.source];
output += ` \`${timeStr}\` ${icon} \`${e.type}\` ${e.summary}\n`;
}