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:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)' },
|
||||
},
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user