diff --git a/api/project-tickets.test.ts b/api/project-tickets.test.ts new file mode 100644 index 0000000..5a532fc --- /dev/null +++ b/api/project-tickets.test.ts @@ -0,0 +1,216 @@ +import { assertEquals } from '@std/assert' +import { describe, it } from '@std/testing/bdd' +import { createDiscordProvider } from './discord-provider.ts' +import { createGithubProvider } from './github-provider.ts' +import { createJiraProvider } from './jira-provider.ts' +import { + filterTickets, + listProjectTickets, + toTicketRow, +} from './project-tickets.ts' +import { + groupIntoTickets, + type Person, + type ProjectScope, + type Provider, + type WorkItem, +} from './tickets.ts' + +const ada: Person = { + id: 'p1', + name: 'Ada Lovelace', + emails: ['ada@example.com'], + githubLogin: 'ada', +} +const grace: Person = { + id: 'p2', + name: 'Grace Hopper', + emails: ['grace@example.com'], + githubLogin: 'grace', +} +const directory = [ada, grace] + +const jira = (key: string, overrides: Partial = {}): WorkItem => ({ + source: 'jira', + externalId: `jira-${key}`, + externalUrl: `https://example.atlassian.net/browse/${key}`, + title: `Jira ${key}`, + raw: { key }, + ...overrides, +}) + +const pr = (title: string, overrides: Partial = {}): WorkItem => ({ + source: 'github', + externalId: `gh-${title}`, + externalUrl: 'https://github.com/01-edu/tournament/pull/1', + title, + status: 'open', + raw: {}, + ...overrides, +}) + +const fakeProvider = (id: Provider['id'], items: WorkItem[]) => { + const scopes: ProjectScope[] = [] + const provider: Provider = { + id, + list: (scope) => { + scopes.push(scope) + return Promise.resolve(items) + }, + comments: () => Promise.resolve([]), + } + return { provider, scopes } +} + +describe('listProjectTickets', () => { + it('merges every provider and sorts by latest activity first', async () => { + const scope = { jiraProjectKey: 'TNT', repositoryUrl: 'https://x/y' } + const jiraSide = fakeProvider('jira', [ + jira('TNT-1', { updatedAt: 100 }), + jira('TNT-2', { updatedAt: 300 }), + jira('TNT-3', { updatedAt: 300 }), + ]) + const githubSide = fakeProvider('github', [ + pr('TNT-1 Fix it', { updatedAt: 500 }), + ]) + + const tickets = await listProjectTickets( + scope, + [jiraSide.provider, githubSide.provider], + () => Promise.resolve(directory), + ) + + assertEquals(jiraSide.scopes, [scope]) + assertEquals(githubSide.scopes, [scope]) + // TNT1 has the newest item (its PR), TNT2/TNT3 tie and fall back to key + assertEquals(tickets.map((ticket) => ticket.key), [ + 'TNT1', + 'TNT2', + 'TNT3', + ]) + assertEquals(tickets[0].items.length, 2) + }) + + it('only queries the providers whose scope field is set', async () => { + const paths: string[] = [] + const store = (path: string) => { + paths.push(path) + return Promise.resolve( + path === 'jira/issue' + ? [{ id: '1', key: 'TNT-1', Summary: 'Only in Jira' }] + : [], + ) + } + + const tickets = await listProjectTickets( + { jiraProjectKey: 'TNT' }, + [ + createJiraProvider(store), + createGithubProvider(store), + createDiscordProvider(store), + ], + () => Promise.resolve([]), + ) + + assertEquals(paths, ['jira/issue']) + assertEquals(tickets.map((ticket) => ticket.key), ['TNT1']) + }) +}) + +describe('filterTickets', () => { + const tickets = groupIntoTickets( + [ + jira('TNT-393', { + title: 'Hide settings for non admin users', + status: 'Done', + assigneeRefs: [{ email: 'ada@example.com' }], + }), + pr('TNT-393 Hide settings tab', { + status: 'merged', + reviewerRefs: [{ login: 'grace' }], + }), + jira('TNT-12', { title: 'Add a leaderboard', status: 'To Do' }), + pr('Bump dependencies'), + ], + directory, + ) + const keys = (filtered = tickets) => filtered.map((ticket) => ticket.key) + + it('returns everything without filters', () => { + assertEquals(keys(filterTickets(tickets, {})), keys()) + }) + + it('filters by deduced status', () => { + assertEquals(keys(filterTickets(tickets, { status: 'done' })), ['TNT393']) + assertEquals(keys(filterTickets(tickets, { status: 'todo' })), ['TNT12']) + }) + + it('filters on tickets that have an item from a source', () => { + assertEquals(keys(filterTickets(tickets, { source: 'jira' })), [ + 'TNT393', + 'TNT12', + ]) + }) + + it('filters by assignee and reviewer person id', () => { + assertEquals(keys(filterTickets(tickets, { assignee: 'p1' })), ['TNT393']) + assertEquals(keys(filterTickets(tickets, { reviewer: 'p2' })), ['TNT393']) + assertEquals(keys(filterTickets(tickets, { reviewer: 'p1' })), []) + }) + + it('searches the key in any notation, and the title', () => { + assertEquals(keys(filterTickets(tickets, { search: 'tnt-393' })), [ + 'TNT393', + ]) + assertEquals(keys(filterTickets(tickets, { search: '12' })), ['TNT12']) + assertEquals(keys(filterTickets(tickets, { search: 'LEADER' })), [ + 'TNT12', + ]) + }) + + it('combines filters', () => { + assertEquals( + keys(filterTickets(tickets, { source: 'jira', status: 'todo' })), + ['TNT12'], + ) + }) + + it('ignores filters left null', () => { + assertEquals( + keys(filterTickets(tickets, { status: null, search: null })), + keys(), + ) + }) +}) + +describe('toTicketRow', () => { + it('keeps raw records, person refs and emails out of the response', () => { + const [ticket] = groupIntoTickets( + [ + jira('TNT-1', { + updatedAt: 100, + assigneeRefs: [{ email: 'ada@example.com' }], + }), + pr('TNT-1 Fix it', { + updatedAt: 200, + reviewerRefs: [{ login: 'grace' }], + }), + ], + directory, + ) + + const row = toTicketRow(ticket) + + assertEquals(row.updatedAt, 200) + assertEquals(row.assignees, [{ id: 'p1', name: 'Ada Lovelace' }]) + assertEquals(row.reviewers, [{ id: 'p2', name: 'Grace Hopper' }]) + assertEquals( + row.items.every((item) => + !('raw' in item) && !('assigneeRefs' in item) && + !('reviewerRefs' in item) + ), + true, + ) + assertEquals('discussion' in row, false) + }) +}) diff --git a/api/project-tickets.ts b/api/project-tickets.ts new file mode 100644 index 0000000..b67c9d9 --- /dev/null +++ b/api/project-tickets.ts @@ -0,0 +1,92 @@ +import { discordProvider } from './discord-provider.ts' +import { githubProvider } from './github-provider.ts' +import { jiraProvider } from './jira-provider.ts' +import { buildTeamDirectory } from './team-directory.ts' +import { + groupIntoTickets, + type Person, + type ProjectScope, + type Provider, + type Source, + type Ticket, + type TicketStatus, +} from './tickets.ts' + +// nullable to take the route's `optional()` params as they are typed +export type TicketFilters = { + status?: TicketStatus | null + source?: Source | null + assignee?: string | null + reviewer?: string | null + search?: string | null +} + +const ticketProviders: Provider[] = [ + jiraProvider, + githubProvider, + discordProvider, +] + +const ticketUpdatedAt = (ticket: Ticket): number => + Math.max(0, ...ticket.items.map((item) => item.updatedAt ?? 0)) + +// latest activity first, then by key so that offset pagination stays stable +// between two requests +const byLatestActivity = (a: Ticket, b: Ticket): number => + ticketUpdatedAt(b) - ticketUpdatedAt(a) || a.key.localeCompare(b.key) + +// each provider returns [] by itself when its scope field is missing +export const listProjectTickets = async ( + scope: ProjectScope, + providers: Provider[] = ticketProviders, + loadDirectory: () => Promise = buildTeamDirectory, +): Promise => { + const [directory, ...results] = await Promise.all([ + loadDirectory(), + ...providers.map((provider) => provider.list(scope)), + ]) + return groupIntoTickets(results.flat(), directory).sort(byLatestActivity) +} + +const hasPerson = (persons: Person[], id: string): boolean => + persons.some((person) => person.id === id) + +// "tnt-393" or "393" find TNT393, anything else is matched against the title +const matchesSearch = (ticket: Ticket, search: string): boolean => { + const term = search.trim().toLowerCase() + const keyTerm = term.toUpperCase().replace(/[^A-Z0-9]/g, '') + return (keyTerm !== '' && ticket.key.includes(keyTerm)) || + ticket.title.toLowerCase().includes(term) +} + +export const filterTickets = ( + tickets: Ticket[], + { status, source, assignee, reviewer, search }: TicketFilters, +): Ticket[] => + tickets.filter((ticket) => + (!status || ticket.status === status) && + (!source || ticket.items.some((item) => item.source === source)) && + (!assignee || hasPerson(ticket.assignees, assignee)) && + (!reviewer || hasPerson(ticket.reviewers, reviewer)) && + (!search || matchesSearch(ticket, search)) + ) + +const toPersonRow = ({ id, name }: Person) => ({ id, name }) + +// what leaves the API: no `raw` source record, no raw person refs, no emails +export const toTicketRow = (ticket: Ticket) => ({ + key: ticket.key, + title: ticket.title, + status: ticket.status, + updatedAt: ticketUpdatedAt(ticket) || undefined, + items: ticket.items.map((item) => ({ + source: item.source, + externalId: item.externalId, + externalUrl: item.externalUrl, + title: item.title, + status: item.status, + updatedAt: item.updatedAt, + })), + assignees: ticket.assignees.map(toPersonRow), + reviewers: ticket.reviewers.map(toPersonRow), +}) diff --git a/api/routes.ts b/api/routes.ts index 9835348..e2e83ba 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -44,6 +44,11 @@ import { get, getOne, set } from './lmdb-store.ts' import { log } from '/api/lib/logger.ts' import { analyzeQueryWithAI } from '/api/fix-query.ts' import { PERSON_LINK_PATH, type PersonLink } from './team-directory.ts' +import { + filterTickets, + listProjectTickets, + toTicketRow, +} from './project-tickets.ts' const MetricSchema = OBJ({ query: STR('The SQL query text'), @@ -194,6 +199,48 @@ const projectOutput = OBJ({ updatedAt: optional(NUM('The last update date of the project')), }) +const TICKET_STATUSES = ['todo', 'in_progress', 'done'] as const +const TICKET_SOURCES = ['jira', 'github', 'discord'] as const +const DEFAULT_TICKET_LIMIT = 50 +const MAX_TICKET_LIMIT = 200 + +const ticketPersonOutput = OBJ({ + id: STR('The person id (Google user id)'), + name: STR('The person full name'), +}) + +const ticketOutput = OBJ({ + key: STR('The ticket key, or `{source}:{externalId}` when unkeyed'), + title: STR('The ticket title, without its key prefix'), + status: LIST(TICKET_STATUSES, 'The status deduced across sources'), + updatedAt: optional(NUM('The latest update across its items, in ms')), + items: ARR( + OBJ({ + source: LIST(TICKET_SOURCES, 'The source system'), + externalId: STR('The id in the source system'), + externalUrl: STR('The link to the item in the source system'), + title: STR('The title in the source system'), + status: optional(STR('The status, in the source vocabulary')), + updatedAt: optional(NUM('The last update in the source, in ms')), + }), + 'One item per linked source record', + ), + assignees: ARR(ticketPersonOutput, 'The resolved assignees'), + reviewers: ARR(ticketPersonOutput, 'The resolved reviewers'), +}) + +// GET params always arrive as strings, NUM would reject them +const toCount = (value: string | null | undefined, fallback: number) => { + if (value == null) return fallback + const count = Number(value) + if (!Number.isInteger(count) || count < 0) { + throw new respond.BadRequestError({ + message: `Expected a non-negative integer, got "${value}"`, + }) + } + return count +} + const identifiersOutput = OBJ({ githubLogin: optional(STR('GitHub login')), discordId: optional(STR('Discord user id')), @@ -485,6 +532,64 @@ const defs = { output: ARR(deploymentOutput, 'List of deployments'), description: 'Get deployments by project ID', }), + 'GET/api/project/tickets': route({ + authorize: withUserSession, + fn: async (ctx, { project: slug, limit, offset, ...filters }) => { + const project = ProjectsCollection.get(slug) + if (!project) { + throw new respond.NotFoundError({ message: 'Project not found' }) + } + if (!project.isPublic && !ctx.session.isAdmin) { + if (!(await userInTeam(project.teamId, ctx.session.id))) { + throw new respond.ForbiddenError({ + message: 'Access to project tickets denied', + }) + } + } + + const start = toCount(offset, 0) + const size = Math.min( + toCount(limit, DEFAULT_TICKET_LIMIT), + MAX_TICKET_LIMIT, + ) + const tickets = filterTickets( + await listProjectTickets({ + repositoryUrl: project.repositoryUrl ?? undefined, + jiraProjectKey: project.jiraProjectKey ?? undefined, + discordChannelId: project.discordChannelId ?? undefined, + }), + filters, + ) + return { + totalRows: tickets.length, + rows: tickets.slice(start, start + size).map(toTicketRow), + } + }, + input: OBJ({ + project: STR('The slug of the project'), + status: optional( + LIST(TICKET_STATUSES, 'Only tickets with this deduced status'), + ), + source: optional( + LIST(TICKET_SOURCES, 'Only tickets with an item from this source'), + ), + assignee: optional(STR('Only tickets assigned to this person id')), + reviewer: optional(STR('Only tickets reviewed by this person id')), + search: optional(STR('Match on the ticket key or title')), + limit: optional( + STR( + `The maximum number of tickets to return (default ${DEFAULT_TICKET_LIMIT}, max ${MAX_TICKET_LIMIT})`, + ), + ), + offset: optional(STR('The number of tickets to skip (default 0)')), + }), + output: OBJ({ + totalRows: NUM('The total number of tickets matching the filters'), + rows: ARR(ticketOutput, 'The requested page of tickets'), + }), + description: + 'List the tickets of a project, aggregated from Jira, GitHub and Discord, latest activity first', + }), 'GET/api/deployment': route({ authorize: withUserSession, fn: async ({ session }, { url }) => {