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.
|
* Link two Jira issues.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ export const toolDefinitions = [
|
|||||||
until: { type: 'string', description: 'ISO8601 timestamp (default: now)' },
|
until: { type: 'string', description: 'ISO8601 timestamp (default: now)' },
|
||||||
sources: {
|
sources: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { type: 'string', enum: ['session', 'note', 'commit', 'plan', 'task_commit'] },
|
items: { type: 'string', enum: ['session', 'note', 'commit', 'plan', 'task_commit', 'jira'] },
|
||||||
description: 'Optional filter: which event sources to include (default: all)',
|
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)' },
|
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
|
// CF-2885: Event Timeline — unified chronological view across session-mcp sources
|
||||||
// Stitches sessions, notes, commits, plans, and task-commit links into a single
|
// Stitches sessions, notes, commits, plans, task-commit links, and Jira history
|
||||||
// time-ordered event stream for LLM consumption.
|
// into a single time-ordered event stream for LLM consumption.
|
||||||
|
|
||||||
import { query } from '../db.js';
|
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 {
|
export interface TimelineEvent {
|
||||||
ts: string; // ISO8601
|
ts: string; // ISO8601
|
||||||
@@ -345,6 +346,107 @@ async function fetchPlanEvents(
|
|||||||
return events;
|
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 ----------
|
// ---------- main tool ----------
|
||||||
|
|
||||||
export async function timeline(args: TimelineArgs): Promise<string> {
|
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 since = resolveSince(args.since);
|
||||||
const until = resolveUntil(args.until);
|
const until = resolveUntil(args.until);
|
||||||
|
const sinceISO = resolveSinceISO(args.since);
|
||||||
|
const untilISO = resolveUntilISO(args.until);
|
||||||
const limit = args.limit ?? 100;
|
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
|
// 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('session') ? fetchSessionEvents(subject, kind, since, until) : Promise.resolve([]),
|
||||||
sourceFilter.has('note') ? fetchNoteEvents(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('commit') ? fetchCommitEvents(subject, kind, since, until) : Promise.resolve([]),
|
||||||
sourceFilter.has('plan') ? fetchPlanEvents(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([]),
|
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)
|
// Sort chronologically (oldest → newest by default for narrative reading)
|
||||||
all.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
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: '🔨',
|
commit: '🔨',
|
||||||
plan: '📋',
|
plan: '📋',
|
||||||
task_commit: '🔗',
|
task_commit: '🔗',
|
||||||
|
jira: '🎫',
|
||||||
}[e.source];
|
}[e.source];
|
||||||
output += ` \`${timeStr}\` ${icon} \`${e.type}\` ${e.summary}\n`;
|
output += ` \`${timeStr}\` ${icon} \`${e.type}\` ${e.summary}\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user