From c81c55331df7e9fb50eccae5fe692b0e9f823ecf Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Mon, 27 Jul 2026 09:46:58 +0000 Subject: [PATCH 1/2] 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() +} From ae00847698b1e5731b23037e537febd22e4b3986 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Wed, 5 Aug 2026 01:01:35 +0000 Subject: [PATCH 2/2] Refactor ticket processing logic and enhance key extraction tests --- api/tickets.test.ts | 23 ++++++++- api/tickets.ts | 110 +++++++++++++++++--------------------------- 2 files changed, 65 insertions(+), 68 deletions(-) diff --git a/api/tickets.test.ts b/api/tickets.test.ts index 40fdbe8..5865537 100644 --- a/api/tickets.test.ts +++ b/api/tickets.test.ts @@ -43,10 +43,16 @@ describe('normalizeKey', () => { assertEquals(normalizeKey('LH92'), 'LH92') }) - it('rejects a value that is not {letters}{digits}', () => { + it('rejects a value with no digits', () => { assertEquals(normalizeKey('Fix'), undefined) assertEquals(normalizeKey(''), undefined) }) + + it('extracts the key and ignores trailing dash-joined words', () => { + // a real branch/PR-title shape: "TNT-879-do-something" is not just + // the key, but the key is still the leading, extractable part of it + assertEquals(normalizeKey('TNT-879-do-something'), 'TNT879') + }) }) describe('extractKey', () => { @@ -73,6 +79,14 @@ describe('extractKey', () => { it('returns undefined when jira.key is missing', () => { assertEquals(extractKey(jiraItem({ raw: {} })), undefined) }) + + it('reads the key from a github title with no space after it', () => { + // e.g. a branch name used as-is for the title, not "{KEY} {title}" + assertEquals( + extractKey(githubItem({ title: 'TNT-879-do-something' })), + 'TNT879', + ) + }) }) describe('deriveStatus', () => { @@ -168,6 +182,13 @@ describe('groupIntoTickets', () => { assertEquals(tickets[0].items, [unkeyed]) }) + it('strips the key prefix from the title regardless of source', () => { + // no jira item here, so the title falls back to the discord item's — + // the prefix stripping must not be hardcoded to github specifically + const tickets = groupIntoTickets([discordItem()], directory) + assertEquals(tickets[0].title, 'Fix login bug') + }) + it('dedupes an assignee resolved from more than one item', () => { const tickets = groupIntoTickets( [ diff --git a/api/tickets.ts b/api/tickets.ts index 89fa33f..8613b93 100644 --- a/api/tickets.ts +++ b/api/tickets.ts @@ -1,11 +1,5 @@ -// 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 @@ -44,8 +38,6 @@ export type Comment = { export type TicketStatus = 'todo' | 'in_progress' | 'done' -// Produced by the aggregator, never stored — always recomputed from -// WorkItem[]. export type Ticket = { key: string title: string @@ -68,14 +60,11 @@ export interface Provider { 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]+$/ +const KEY_PATTERN = /^([A-Z]+)[^0-9]?([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 + const [, prefix, id] = rawKey.toUpperCase().match(KEY_PATTERN) ?? [] + return id ? `${prefix}${id}` : undefined } export const extractKey = (item: WorkItem): string | undefined => { @@ -86,16 +75,11 @@ export const extractKey = (item: WorkItem): string | undefined => { 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 + return thread?.name ? normalizeKey(thread.name) : undefined } + case 'github': + return normalizeKey(item.title) default: { const exhaustive: never = item.source return exhaustive @@ -117,11 +101,6 @@ const JIRA_STATUS_MAP: Record = { 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' @@ -130,67 +109,64 @@ export const deriveStatus = (items: WorkItem[]): TicketStatus => { return mapJiraStatus(jira?.status) ?? 'todo' } +const matchesPersonRef = (ref: PersonRef, person: Person): boolean => + (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) + +function findPersonMatch(this: PersonRef, person: Person): boolean { + return matchesPersonRef(this, person) +} + 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) - ) +): Person | undefined => directory.find(findPersonMatch, ref) -// 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 resolveUnique = ( + directory: Person[], + items: WorkItem[], + personKey: 'assigneeRefs' | 'reviewerRefs', +): Person[] => { + const persons = new Set() + for (const item of items) { + for (const ref of item[personKey] ?? []) { + const match = directory.find(findPersonMatch, ref) + match && persons.add(match) + } + } + return [...persons] } -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 - } +const stripKeyPrefix = (title: string, key: string): string => { + const [prefix, ...rest] = title.split(' ') + return prefix && normalizeKey(prefix) === key + ? rest.join(' ') || title + : title +} - return items[0].title +const pickTitle = (items: WorkItem[], key: string): string => { + const jira = items.find((item) => item.source === 'jira') + return stripKeyPrefix((jira ?? items[0]).title, key) } -// 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}`, + (item) => extractKey(item) || `${item.source}:${item.externalId}`, ) return groups.entries().map(([key, groupItems]) => ({ key, - title: pickTitle(groupItems), + title: pickTitle(groupItems, key), status: deriveStatus(groupItems), items: groupItems, discussion: [], - assignees: resolveUnique( - directory, - groupItems.flatMap((item) => item.assigneeRefs ?? []), - ), - reviewers: resolveUnique( - directory, - groupItems.flatMap((item) => item.reviewerRefs ?? []), - ), + assignees: resolveUnique(directory, groupItems, 'assigneeRefs'), + reviewers: resolveUnique(directory, groupItems, 'reviewerRefs'), })).toArray() }