From c81c55331df7e9fb50eccae5fe692b0e9f823ecf Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Mon, 27 Jul 2026 09:46:58 +0000 Subject: [PATCH] Implement pure logic and types for task manager ticket aggregation --- api/tickets.test.ts | 182 ++++++++++++++++++++++++++++++++++++++++ api/tickets.ts | 196 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 api/tickets.test.ts create mode 100644 api/tickets.ts diff --git a/api/tickets.test.ts b/api/tickets.test.ts new file mode 100644 index 0000000..40fdbe8 --- /dev/null +++ b/api/tickets.test.ts @@ -0,0 +1,182 @@ +import { describe, it } from '@std/testing/bdd' +import { assertEquals } from '@std/assert' +import { + deriveStatus, + extractKey, + groupIntoTickets, + normalizeKey, + type Person, + resolvePerson, + type WorkItem, +} from './tickets.ts' + +const jiraItem = (overrides: Partial = {}): WorkItem => ({ + source: 'jira', + externalId: 'jira-1', + externalUrl: 'https://example.atlassian.net/browse/LH92', + title: 'Fix login bug', + raw: { key: 'LH-92' }, + ...overrides, +}) + +const githubItem = (overrides: Partial = {}): WorkItem => ({ + source: 'github', + externalId: 'gh-1', + externalUrl: 'https://github.com/01edu/license-hub/pull/1', + title: 'LH92 Fix login bug', + raw: {}, + ...overrides, +}) + +const discordItem = (overrides: Partial = {}): WorkItem => ({ + source: 'discord', + externalId: 'channel-1', + externalUrl: 'https://discord.com/channels/1/channel-1', + title: 'LH92 Fix login bug', + raw: { thread: { name: 'LH92 Fix login bug' } }, + ...overrides, +}) + +describe('normalizeKey', () => { + it('normalizes a dashed key and a non-dashed key to the same value', () => { + assertEquals(normalizeKey('LH-92'), 'LH92') + assertEquals(normalizeKey('LH92'), 'LH92') + }) + + it('rejects a value that is not {letters}{digits}', () => { + assertEquals(normalizeKey('Fix'), undefined) + assertEquals(normalizeKey(''), undefined) + }) +}) + +describe('extractKey', () => { + it('reads the key straight off jira.key', () => { + assertEquals(extractKey(jiraItem()), 'LH92') + }) + + it('reads the key from the discord thread name prefix', () => { + assertEquals(extractKey(discordItem()), 'LH92') + }) + + it('returns undefined when the discord item has no thread', () => { + assertEquals(extractKey(discordItem({ raw: {} })), undefined) + }) + + it('reads the key from the github title prefix', () => { + assertEquals(extractKey(githubItem()), 'LH92') + }) + + it('returns undefined, not a wrong key, when github has no key prefix', () => { + assertEquals(extractKey(githubItem({ title: 'Fix login bug' })), undefined) + }) + + it('returns undefined when jira.key is missing', () => { + assertEquals(extractKey(jiraItem({ raw: {} })), undefined) + }) +}) + +describe('deriveStatus', () => { + it('is done when the github PR is merged', () => { + assertEquals( + deriveStatus([ + jiraItem({ status: 'In Progress' }), + githubItem({ status: 'merged' }), + ]), + 'done', + ) + }) + + it('is in_progress when the github PR is open, even if jira says todo', () => { + assertEquals( + deriveStatus([ + jiraItem({ status: 'To Do' }), + githubItem({ status: 'open' }), + ]), + 'in_progress', + ) + }) + + it('falls back to the mapped jira status with no github item', () => { + assertEquals(deriveStatus([jiraItem({ status: 'Done' })]), 'done') + }) + + it('defaults to todo with no recognizable signal', () => { + assertEquals(deriveStatus([]), 'todo') + }) +}) + +describe('resolvePerson', () => { + const directory: Person[] = [ + { + id: 'p1', + name: 'Ada Lovelace', + emails: ['ada@example.com'], + githubLogin: 'ada', + discordId: 'discord-ada', + jiraAccountId: 'jira-ada', + }, + ] + + it('matches by github login', () => { + assertEquals(resolvePerson(directory, { login: 'ada' })?.id, 'p1') + }) + + it('matches by email', () => { + assertEquals( + resolvePerson(directory, { email: 'ada@example.com' })?.id, + 'p1', + ) + }) + + it('returns undefined when nobody matches', () => { + assertEquals(resolvePerson(directory, { login: 'nobody' }), undefined) + }) +}) + +describe('groupIntoTickets', () => { + const directory: Person[] = [ + { + id: 'p1', + name: 'Ada Lovelace', + emails: ['ada@example.com'], + githubLogin: 'ada', + }, + ] + + it('merges a jira issue and its github PR into one ticket', () => { + const tickets = groupIntoTickets( + [ + jiraItem({ status: 'In Progress' }), + githubItem({ status: 'open', assigneeRefs: [{ login: 'ada' }] }), + ], + directory, + ) + + assertEquals(tickets.length, 1) + assertEquals(tickets[0].key, 'LH92') + assertEquals(tickets[0].items.length, 2) + assertEquals(tickets[0].status, 'in_progress') + assertEquals(tickets[0].assignees.map((p) => p.id), ['p1']) + }) + + it('keeps an unkeyed item as its own single-item ticket', () => { + const unkeyed = githubItem({ title: 'Fix login bug' }) + const tickets = groupIntoTickets([unkeyed], []) + + assertEquals(tickets.length, 1) + assertEquals(tickets[0].key, `${unkeyed.source}:${unkeyed.externalId}`) + assertEquals(tickets[0].items, [unkeyed]) + }) + + it('dedupes an assignee resolved from more than one item', () => { + const tickets = groupIntoTickets( + [ + jiraItem({ assigneeRefs: [{ email: 'ada@example.com' }] }), + githubItem({ assigneeRefs: [{ login: 'ada' }] }), + ], + directory, + ) + + assertEquals(tickets[0].assignees.length, 1) + }) +}) diff --git a/api/tickets.ts b/api/tickets.ts new file mode 100644 index 0000000..89fa33f --- /dev/null +++ b/api/tickets.ts @@ -0,0 +1,196 @@ +// Types and pure logic for the read-only, cross-source task manager +// aggregator. See TASK_MANAGER_INTEGRATION.md for the design this +// implements (§1-§4) and TASK_MANAGER_ISSUES.md, issue 1. + +export type Source = 'github' | 'jira' | 'discord' + +// Whatever identifier a source hands us — resolved against the team +// directory in resolvePerson, never shown to a user as-is. +export type PersonRef = { + login?: string + email?: string + discordId?: string + jiraAccountId?: string +} + +export type Person = { + id: string + name: string + emails: string[] + githubLogin?: string + discordId?: string + jiraAccountId?: string +} + +export type WorkItem = { + source: Source + externalId: string + externalUrl: string + title: string + status?: string + updatedAt?: number + assigneeRefs?: PersonRef[] + reviewerRefs?: PersonRef[] + raw?: unknown +} + +export type Comment = { + source: Source + author?: string + body: string + url?: string + createdAt?: number +} + +export type TicketStatus = 'todo' | 'in_progress' | 'done' + +// Produced by the aggregator, never stored — always recomputed from +// WorkItem[]. +export type Ticket = { + key: string + title: string + status: TicketStatus + items: WorkItem[] + discussion: Comment[] + assignees: Person[] + reviewers: Person[] +} + +export type ProjectScope = { + repositoryUrl?: string + jiraProjectKey?: string + discordChannelId?: string +} + +export interface Provider { + id: Source + list(scope: ProjectScope): Promise + comments(item: WorkItem): Promise +} + +// A real ticket key is always {LETTERS}{DIGITS} once normalized (e.g. +// LH92, SUP2078) — enforcing the shape here means a source that doesn't +// follow the naming convention yields no key, never a wrong one. +const KEY_SHAPE = /^[A-Z]+[0-9]+$/ + +export const normalizeKey = (rawKey: string): string | undefined => { + const normalized = rawKey.toUpperCase().replace(/[^A-Z0-9]/g, '') + return KEY_SHAPE.test(normalized) ? normalized : undefined +} + +export const extractKey = (item: WorkItem): string | undefined => { + const raw = item.raw as Record | undefined + switch (item.source) { + case 'jira': { + const key = raw?.key + return typeof key === 'string' ? normalizeKey(key) : undefined + } + case 'discord': { + // The thread name is prefixed with the key, e.g. "LH92 Fix bug". + const thread = raw?.thread as { name?: string } | undefined + const [prefix] = thread?.name?.split(' ') ?? [] + return prefix ? normalizeKey(prefix) : undefined + } + case 'github': { + // The title is prefixed with the key by convention, same idea. + const [prefix] = item.title.split(' ') + return prefix ? normalizeKey(prefix) : undefined + } + default: { + const exhaustive: never = item.source + return exhaustive + } + } +} + +const JIRA_STATUS_MAP: Record = { + 'to do': 'todo', + 'todo': 'todo', + 'backlog': 'todo', + 'in progress': 'in_progress', + 'in review': 'in_progress', + 'done': 'done', + 'closed': 'done', + 'resolved': 'done', +} + +const mapJiraStatus = (status?: string): TicketStatus | undefined => + status ? JIRA_STATUS_MAP[status.toLowerCase()] : undefined + +// A GitHub PR is stronger evidence of real progress than Jira's own status +// column, so it takes priority when both are present. Recomputed from the +// current state of each source every time — nothing is stored, so a +// reopened PR simply stops being "merged" on the next read and the +// deduced status drops back down on its own. +export const deriveStatus = (items: WorkItem[]): TicketStatus => { + const pr = items.find((item) => item.source === 'github') + if (pr?.status === 'merged') return 'done' + if (pr?.status === 'open') return 'in_progress' + const jira = items.find((item) => item.source === 'jira') + return mapJiraStatus(jira?.status) ?? 'todo' +} + +export const resolvePerson = ( + directory: Person[], + ref: PersonRef, +): Person | undefined => + directory.find((person) => + (ref.login != null && person.githubLogin === ref.login) || + (ref.email != null && person.emails.includes(ref.email)) || + (ref.discordId != null && person.discordId === ref.discordId) || + (ref.jiraAccountId != null && person.jiraAccountId === ref.jiraAccountId) + ) + +// A ref that fails to resolve (nobody in the directory matches) is +// dropped rather than shown as a raw login/email — the point is one +// unified list of people, not a leak of per-source identifiers. +const resolveUnique = (directory: Person[], refs: PersonRef[]): Person[] => { + const resolved = refs + .map((ref) => resolvePerson(directory, ref)) + .filter((person): person is Person => person != null) + return [...new Map(resolved.map((person) => [person.id, person])).values()] +} + +const pickTitle = (items: WorkItem[]): string => { + const jira = items.find((item) => item.source === 'jira') + if (jira) return jira.title + + const github = items.find((item) => item.source === 'github') + if (github) { + const withoutKeyPrefix = github.title.split(' ').slice(1).join(' ') + return withoutKeyPrefix || github.title + } + + return items[0].title +} + +// Groups WorkItems into Tickets by their canonical key (see extractKey); +// an item with no extractable key surfaces as its own single-item Ticket +// instead of being dropped. Pure: no I/O, discussion is always empty here +// — populating it requires calling Provider.comments(), which belongs to +// the aggregator that wires providers together, not this module. +export const groupIntoTickets = ( + items: WorkItem[], + directory: Person[], +): Ticket[] => { + const groups = Map.groupBy( + items, + (item) => extractKey(item) ?? `${item.source}:${item.externalId}`, + ) + + return groups.entries().map(([key, groupItems]) => ({ + key, + title: pickTitle(groupItems), + status: deriveStatus(groupItems), + items: groupItems, + discussion: [], + assignees: resolveUnique( + directory, + groupItems.flatMap((item) => item.assigneeRefs ?? []), + ), + reviewers: resolveUnique( + directory, + groupItems.flatMap((item) => item.reviewerRefs ?? []), + ), + })).toArray() +}