diff --git a/api/jira-provider.test.ts b/api/jira-provider.test.ts new file mode 100644 index 0000000..8329298 --- /dev/null +++ b/api/jira-provider.test.ts @@ -0,0 +1,100 @@ +import { assertEquals } from '@std/assert' +import { describe, it } from '@std/testing/bdd' +import { createJiraProvider } from './jira-provider.ts' + +const issue = { + id: '10001', + key: 'TNT-393', + Project: { + id: '10017', + key: 'TNT', + name: 'Tournament', + }, + Summary: 'Hide settings for no admin tournament user', + Status: { + name: 'Done', + }, + Updated: '2026-01-02T12:00:00.000Z', + Created: '2026-01-01T12:00:00.000Z', + Description: 'The settings tab should not be visible to non-admin users.', + Assignee: { + accountId: 'jira-account-1', + emailAddress: 'ada@example.com', + displayName: 'Ada Lovelace', + }, + Comment: { + comments: [ + { + body: 'Implemented in the tournament frontend.', + author: { + accountId: 'jira-account-2', + emailAddress: 'grace@example.com', + displayName: 'Grace Hopper', + }, + created: '2026-01-02T10:00:00.000Z', + }, + ], + }, +} + +describe('jiraProvider', () => { + it('lists issues scoped by Jira project and maps the assignee', async () => { + let receivedParams: { q?: string } = {} + const provider = createJiraProvider((_path, params) => { + receivedParams = params ?? {} + return Promise.resolve([issue]) + }) + + const items = await provider.list({ jiraProjectKey: 'TNT' }) + + assertEquals(receivedParams.q, `select(.Project.key == "TNT") | { + id, + key, + Summary, + Status: { + name: .Status.name, + iconUrl: .Status.iconUrl + }, + Updated, + Assignee: { + accountId: .Assignee.accountId, + emailAddress: .Assignee.emailAddress + } + }`) + assertEquals(items.length, 1) + assertEquals(items[0].externalId, '10001') + assertEquals(items[0].title, issue.Summary) + assertEquals(items[0].status, issue.Status.name) + assertEquals(items[0].assigneeRefs, [{ + jiraAccountId: 'jira-account-1', + email: 'ada@example.com', + }]) + }) + + it('returns no records when the project scope is absent', async () => { + let called = false + const provider = createJiraProvider(() => { + called = true + return Promise.resolve([issue]) + }) + + assertEquals(await provider.list({}), []) + assertEquals(called, false) + }) + + it('returns description first, followed by Jira comments', async () => { + const provider = createJiraProvider(() => Promise.resolve([issue])) + const [item] = await provider.list({ jiraProjectKey: 'TNT' }) + const discussion = await provider.comments(item) + + assertEquals(discussion.map((comment) => comment.body), [ + issue.Description, + 'Implemented in the tournament frontend.', + ]) + assertEquals(discussion[1].author, 'grace@example.com') + assertEquals( + discussion[1].createdAt, + Date.parse('2026-01-02T10:00:00.000Z'), + ) + }) +}) diff --git a/api/jira-provider.ts b/api/jira-provider.ts new file mode 100644 index 0000000..09a239f --- /dev/null +++ b/api/jira-provider.ts @@ -0,0 +1,174 @@ +import { get } from './lmdb-store.ts' +import type { + Comment, + PersonRef, + ProjectScope, + Provider, + WorkItem, +} from './tickets.ts' + +type JiraUser = { + accountId?: string + emailAddress?: string | null +} + +type JiraStatus = { + name?: string + iconUrl?: string +} | null + +type JiraText = string | Record + +type JiraComment = { + body?: JiraText + author?: JiraUser | null + created?: string +} + +type JiraIssue = { + id?: string + key?: string + Project?: { + key?: string + } | null + + Summary?: string | null + Status?: JiraStatus + Updated?: string + Created?: string + Description?: JiraText | null + Assignee?: JiraUser | null + Comment?: { + comments?: JiraComment[] + } | null +} + +type StoreGet = ( + path: string, + params?: { q?: string }, +) => Promise + +const jiraUserRef = ( + user: JiraUser | null | undefined, +): PersonRef | undefined => { + if (!user?.accountId && !user?.emailAddress) return undefined + return { + jiraAccountId: user.accountId, + email: user.emailAddress ?? undefined, + } +} + +const toWorkItem = (issue: JiraIssue): WorkItem | undefined => { + if (!issue.id || !issue.key) return undefined + + const assignee = jiraUserRef(issue.Assignee) + return { + source: 'jira', + externalId: issue.id, + externalUrl: issue.Status?.iconUrl + ? `${new URL(issue.Status.iconUrl).origin}/browse/${ + encodeURIComponent(issue.key) + }` + : `/browse/${encodeURIComponent(issue.key)}`, + title: issue.Summary ?? issue.key, + status: typeof issue.Status === 'string' + ? issue.Status + : issue.Status?.name, + updatedAt: issue.Updated ? Date.parse(issue.Updated) : undefined, + assigneeRefs: assignee ? [assignee] : [], + raw: issue, + } +} + +const toComment = ( + item: WorkItem, + body: JiraText | null | undefined, + author: JiraUser | null | undefined, + created: string | undefined, +): Comment | undefined => { + if (typeof body !== 'string' || !body) return undefined + return { + source: 'jira', + author: author?.emailAddress ?? undefined, + body, + url: item.externalUrl, + createdAt: created ? Date.parse(created) : undefined, + } +} + +export const createJiraProvider = ( + storeGet: StoreGet = (path, params) => get(path, params), +): Provider => ({ + id: 'jira', + + async list(scope: ProjectScope): Promise { + if (!scope.jiraProjectKey) return [] + const issues = await storeGet('jira/issue', { + q: `select(.Project.key == "${scope.jiraProjectKey}") | { + id, + key, + Summary, + Status: { + name: .Status.name, + iconUrl: .Status.iconUrl + }, + Updated, + Assignee: { + accountId: .Assignee.accountId, + emailAddress: .Assignee.emailAddress + } + }`, + }) + const items: WorkItem[] = [] + for (const issue of issues) { + const item = toWorkItem(issue) + if (item) items.push(item) + } + return items + }, + + async comments(item: WorkItem): Promise { + const issues = await storeGet('jira/issue', { + q: `select(.id == "${item.externalId}") | { + id, + key, + Summary, + Created, + Description, + Comment: { + comments: ((.Comment.comments // []) | map({ + body, + created, + author: { emailAddress: .author.emailAddress } + })) + } + }`, + }) + + if (issues.length === 0 || !issues[0]) return [] + const issue = issues[0] + + const comments: Comment[] = [] + const description = toComment( + item, + issue.Description, + undefined, + issue.Created, + ) + if (description) comments.push(description) + + for (const record of issue.Comment?.comments ?? []) { + const comment = toComment( + item, + record.body, + record.author, + record.created, + ) + if (comment) comments.push(comment) + } + + return comments + }, +}) + +export const jiraProvider = createJiraProvider()