-
Notifications
You must be signed in to change notification settings - Fork 0
DT-157: Task manager — types & pure logic (WorkItem, Ticket, key/status/person functions) #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> = {}): 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> = {}): 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> = {}): 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) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<WorkItem[]> | ||
| comments(item: WorkItem): Promise<Comment[]> | ||
| } | ||
|
|
||
| // 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<string, unknown> | 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<string, TicketStatus> = { | ||
| '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) | ||
| ) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. export const resolvePerson = (ref: PersonRef, person: 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)
)
function resolvePersonMethod(person: Person) {
return resolvePerson(person, this)
}
directory.find(resolvePersonMethod, 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. const persons = new Set<Person>()
for (const ref of refs) {
const match = directory.find(resolvePerson, ref)
match && persons.add(match)
}
return [...persons]// if match ref is not stable:
const persons = new Map<string, Person>()
for (const ref of refs) {
const match = directory.find(resolvePerson, ref)
match && persons.set(match.id, match)
}
return [...persons.values()] |
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think here we could have a more generic "if this title has the project prefix clean it up" and not even care about the specific source |
||
| } | ||
|
|
||
| // 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}`, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
we don't want to accept falsy values as valid keys |
||
| ) | ||
|
|
||
| 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 ?? []), | ||
| ), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. resolveUnique(directory, groupItems, 'reviewerRefs')
// later
const persons = new Set<Person>()
for (const item of items) {
if (!item[personKey]) continue
for (const ref of item[personKey]) {
const match = directory.find(resolvePerson, ref)
match && persons.add(match)
}
}
return [...persons] |
||
| })).toArray() | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TNT-879-do-something would fail
/^([A-Z]+)[^0-9]?([0-9]+)/would normalize all