From 9d8d6da6d460ebe55cbc8345c9987d671556c739 Mon Sep 17 00:00:00 2001 From: Enrique Gonzalez Date: Wed, 7 Aug 2024 08:38:56 -0700 Subject: [PATCH] add zendesk as a mentions and items provider --- pnpm-lock.yaml | 6 ++ provider/zendesk/api.ts | 122 +++++++++++++++++++++++++++++++++ provider/zendesk/index.ts | 96 ++++++++++++++++++++++++++ provider/zendesk/package.json | 26 +++++++ provider/zendesk/tsconfig.json | 12 ++++ 5 files changed, 262 insertions(+) create mode 100644 provider/zendesk/api.ts create mode 100644 provider/zendesk/index.ts create mode 100644 provider/zendesk/package.json create mode 100644 provider/zendesk/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8d88bb0..2e522afd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -730,6 +730,12 @@ importers: specifier: workspace:* version: link:../../lib/provider + provider/zendesk: + dependencies: + '@openctx/provider': + specifier: workspace:* + version: link:../../lib/provider + web: dependencies: '@code-hike/mdx': diff --git a/provider/zendesk/api.ts b/provider/zendesk/api.ts new file mode 100644 index 00000000..217127e7 --- /dev/null +++ b/provider/zendesk/api.ts @@ -0,0 +1,122 @@ +import type { Settings } from './index.ts' + +export interface TicketPickerItem { + id: number + subject: string + url: string +} + +export interface Ticket { + id: number + url: string + subject: string + description: string + tags: string[] + status: string + priority: string + created_at: string + updated_at: string + comments: TicketComment[] +} + +export interface TicketComment { + id: number + type: string + author_id: number + body: string + html_body: string + plain_body: string + public: boolean + created_at: string +} + +const authHeaders = (settings: Settings) => ({ + Authorization: `Basic ${Buffer.from(`${settings.email}/token:${settings.apiToken}`).toString('base64')}`, +}) + +const buildUrl = (settings: Settings, path: string, searchParams: Record = {}) => { + const url = new URL(`https://${settings.subdomain}.zendesk.com/api/v2${path}`) + url.search = new URLSearchParams(searchParams).toString() + return url +} + +export const searchTickets = async ( + query: string | undefined, + settings: Settings, +): Promise => { + const searchResponse = await fetch( + buildUrl(settings, '/search.json', { + query: `type:ticket ${query || ''}`, + }), + { + method: 'GET', + headers: authHeaders(settings), + }, + ) + if (!searchResponse.ok) { + throw new Error( + `Error searching Zendesk tickets (${searchResponse.status} ${ + searchResponse.statusText + }): ${await searchResponse.text()}`, + ) + } + + const searchJSON = (await searchResponse.json()) as { + results: { + id: number + subject: string + url: string + }[] + } + + return searchJSON.results.map(ticket => ({ + id: ticket.id, + subject: ticket.subject, + url: ticket.url, + })) +} + +export const fetchTicket = async (ticketId: number, settings: Settings): Promise => { + const ticketResponse = await fetch( + buildUrl(settings, `/tickets/${ticketId}.json`), + { + method: 'GET', + headers: authHeaders(settings), + } + ) + if (!ticketResponse.ok) { + throw new Error( + `Error fetching Zendesk ticket (${ticketResponse.status} ${ + ticketResponse.statusText + }): ${await ticketResponse.text()}` + ) + } + + const responseJSON = (await ticketResponse.json()) as { ticket: Ticket } + const ticket = responseJSON.ticket + + if (!ticket) { + return null + } + + // Fetch comments for the ticket + const commentsResponse = await fetch( + buildUrl(settings, `/tickets/${ticketId}/comments.json`), + { + method: 'GET', + headers: authHeaders(settings), + } + ) + if (!commentsResponse.ok) { + throw new Error( + `Error fetching Zendesk ticket comments (${commentsResponse.status} ${ + commentsResponse.statusText + }): ${await commentsResponse.text()}` + ) + } + + const commentsJSON = (await commentsResponse.json()) as { comments: TicketComment[] } + ticket.comments = commentsJSON.comments + + return ticket +} diff --git a/provider/zendesk/index.ts b/provider/zendesk/index.ts new file mode 100644 index 00000000..e7486c29 --- /dev/null +++ b/provider/zendesk/index.ts @@ -0,0 +1,96 @@ +import type { + Item, + ItemsParams, + ItemsResult, + MentionsParams, + MentionsResult, + MetaParams, + MetaResult, + Provider, +} from '@openctx/provider' +import { type Ticket, fetchTicket, searchTickets } from './api.js' + +export type Settings = { + subdomain: string + email: string + apiToken: string +} + +const checkSettings = (settings: Settings) => { + const missingKeys = ['subdomain', 'email', 'apiToken'].filter(key => !(key in settings)) + if (missingKeys.length > 0) { + throw new Error(`Missing settings: ${JSON.stringify(missingKeys)}`) + } +} + +const ticketToItem = (ticket: Ticket): Item => ({ + url: ticket.url, + title: ticket.subject, + ui: { + hover: { + markdown: ticket.description, + text: ticket.description || ticket.subject, + }, + }, + ai: { + content: + `The following represents contents of the Zendesk ticket ${ticket.id}: ` + + JSON.stringify({ + ticket: { + id: ticket.id, + subject: ticket.subject, + url: ticket.url, + description: ticket.description, + tags: ticket.tags, + status: ticket.status, + priority: ticket.priority, + created_at: ticket.created_at, + updated_at: ticket.updated_at, + comments: ticket.comments.map(comment => ({ + id: comment.id, + type: comment.type, + author_id: comment.author_id, + body: comment.body, + html_body: comment.html_body, + plain_body: comment.plain_body, + public: comment.public, + created_at: comment.created_at, + })) + }, + }), + }, +}) + +const zendeskProvider: Provider = { + meta(params: MetaParams, settings: Settings): MetaResult { + return { name: 'Zendesk', mentions: { label: 'Search by subject, id, or paste url...' } } + }, + async mentions(params: MentionsParams, settings: Settings): Promise { + checkSettings(settings) + + return searchTickets(params.query, settings).then(items => + items.map(item => ({ + title: `#${item.id}`, + uri: item.url, + description: item.subject, + data: { id: item.id }, + })), + ) + }, + + async items(params: ItemsParams, settings: Settings): Promise { + checkSettings(settings) + + const id = (params.mention?.data as { id: number }).id + + const ticket = await fetchTicket(id, settings) + + if (!ticket) { + return [] + } + + return [ticketToItem(ticket)] + }, +} + +export default zendeskProvider diff --git a/provider/zendesk/package.json b/provider/zendesk/package.json new file mode 100644 index 00000000..bcccc63f --- /dev/null +++ b/provider/zendesk/package.json @@ -0,0 +1,26 @@ +{ + "name": "@openctx/zendesk", + "version": "0.0.1", + "description": "Use information from Zendesk (OpenCtx provider)", + "license": "Apache-2.0", + "homepage": "https://openctx.org/docs/providers/zendesk", + "repository": { + "type": "git", + "url": "https://github.com/sourcegraph/openctx", + "directory": "provider/zendesk" + }, + "type": "module", + "main": "dist/bundle.js", + "types": "dist/index.d.ts", + "files": ["dist/bundle.js", "dist/index.d.ts"], + "sideEffects": false, + "scripts": { + "bundle": "tsc --build && esbuild --log-level=error --bundle --format=esm --outfile=dist/bundle.js index.ts", + "prepublishOnly": "tsc --build --clean && npm run --silent bundle", + "test": "vitest" + }, + "dependencies": { + "@openctx/provider": "workspace:*" + } + } + \ No newline at end of file diff --git a/provider/zendesk/tsconfig.json b/provider/zendesk/tsconfig.json new file mode 100644 index 00000000..babc3d6d --- /dev/null +++ b/provider/zendesk/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../.config/tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "lib": ["ESNext"] + }, + "include": ["*.ts"], + "exclude": ["dist", "vitest.config.ts"], + "references": [{ "path": "../../lib/provider" }] + } + \ No newline at end of file