Skip to content

Commit 9d8d6da

Browse files
committed
add zendesk as a mentions and items provider
1 parent 779a4b2 commit 9d8d6da

File tree

5 files changed

+262
-0
lines changed

5 files changed

+262
-0
lines changed

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

provider/zendesk/api.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import type { Settings } from './index.ts'
2+
3+
export interface TicketPickerItem {
4+
id: number
5+
subject: string
6+
url: string
7+
}
8+
9+
export interface Ticket {
10+
id: number
11+
url: string
12+
subject: string
13+
description: string
14+
tags: string[]
15+
status: string
16+
priority: string
17+
created_at: string
18+
updated_at: string
19+
comments: TicketComment[]
20+
}
21+
22+
export interface TicketComment {
23+
id: number
24+
type: string
25+
author_id: number
26+
body: string
27+
html_body: string
28+
plain_body: string
29+
public: boolean
30+
created_at: string
31+
}
32+
33+
const authHeaders = (settings: Settings) => ({
34+
Authorization: `Basic ${Buffer.from(`${settings.email}/token:${settings.apiToken}`).toString('base64')}`,
35+
})
36+
37+
const buildUrl = (settings: Settings, path: string, searchParams: Record<string, string> = {}) => {
38+
const url = new URL(`https://${settings.subdomain}.zendesk.com/api/v2${path}`)
39+
url.search = new URLSearchParams(searchParams).toString()
40+
return url
41+
}
42+
43+
export const searchTickets = async (
44+
query: string | undefined,
45+
settings: Settings,
46+
): Promise<TicketPickerItem[]> => {
47+
const searchResponse = await fetch(
48+
buildUrl(settings, '/search.json', {
49+
query: `type:ticket ${query || ''}`,
50+
}),
51+
{
52+
method: 'GET',
53+
headers: authHeaders(settings),
54+
},
55+
)
56+
if (!searchResponse.ok) {
57+
throw new Error(
58+
`Error searching Zendesk tickets (${searchResponse.status} ${
59+
searchResponse.statusText
60+
}): ${await searchResponse.text()}`,
61+
)
62+
}
63+
64+
const searchJSON = (await searchResponse.json()) as {
65+
results: {
66+
id: number
67+
subject: string
68+
url: string
69+
}[]
70+
}
71+
72+
return searchJSON.results.map(ticket => ({
73+
id: ticket.id,
74+
subject: ticket.subject,
75+
url: ticket.url,
76+
}))
77+
}
78+
79+
export const fetchTicket = async (ticketId: number, settings: Settings): Promise<Ticket | null> => {
80+
const ticketResponse = await fetch(
81+
buildUrl(settings, `/tickets/${ticketId}.json`),
82+
{
83+
method: 'GET',
84+
headers: authHeaders(settings),
85+
}
86+
)
87+
if (!ticketResponse.ok) {
88+
throw new Error(
89+
`Error fetching Zendesk ticket (${ticketResponse.status} ${
90+
ticketResponse.statusText
91+
}): ${await ticketResponse.text()}`
92+
)
93+
}
94+
95+
const responseJSON = (await ticketResponse.json()) as { ticket: Ticket }
96+
const ticket = responseJSON.ticket
97+
98+
if (!ticket) {
99+
return null
100+
}
101+
102+
// Fetch comments for the ticket
103+
const commentsResponse = await fetch(
104+
buildUrl(settings, `/tickets/${ticketId}/comments.json`),
105+
{
106+
method: 'GET',
107+
headers: authHeaders(settings),
108+
}
109+
)
110+
if (!commentsResponse.ok) {
111+
throw new Error(
112+
`Error fetching Zendesk ticket comments (${commentsResponse.status} ${
113+
commentsResponse.statusText
114+
}): ${await commentsResponse.text()}`
115+
)
116+
}
117+
118+
const commentsJSON = (await commentsResponse.json()) as { comments: TicketComment[] }
119+
ticket.comments = commentsJSON.comments
120+
121+
return ticket
122+
}

provider/zendesk/index.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type {
2+
Item,
3+
ItemsParams,
4+
ItemsResult,
5+
MentionsParams,
6+
MentionsResult,
7+
MetaParams,
8+
MetaResult,
9+
Provider,
10+
} from '@openctx/provider'
11+
import { type Ticket, fetchTicket, searchTickets } from './api.js'
12+
13+
export type Settings = {
14+
subdomain: string
15+
email: string
16+
apiToken: string
17+
}
18+
19+
const checkSettings = (settings: Settings) => {
20+
const missingKeys = ['subdomain', 'email', 'apiToken'].filter(key => !(key in settings))
21+
if (missingKeys.length > 0) {
22+
throw new Error(`Missing settings: ${JSON.stringify(missingKeys)}`)
23+
}
24+
}
25+
26+
const ticketToItem = (ticket: Ticket): Item => ({
27+
url: ticket.url,
28+
title: ticket.subject,
29+
ui: {
30+
hover: {
31+
markdown: ticket.description,
32+
text: ticket.description || ticket.subject,
33+
},
34+
},
35+
ai: {
36+
content:
37+
`The following represents contents of the Zendesk ticket ${ticket.id}: ` +
38+
JSON.stringify({
39+
ticket: {
40+
id: ticket.id,
41+
subject: ticket.subject,
42+
url: ticket.url,
43+
description: ticket.description,
44+
tags: ticket.tags,
45+
status: ticket.status,
46+
priority: ticket.priority,
47+
created_at: ticket.created_at,
48+
updated_at: ticket.updated_at,
49+
comments: ticket.comments.map(comment => ({
50+
id: comment.id,
51+
type: comment.type,
52+
author_id: comment.author_id,
53+
body: comment.body,
54+
html_body: comment.html_body,
55+
plain_body: comment.plain_body,
56+
public: comment.public,
57+
created_at: comment.created_at,
58+
}))
59+
},
60+
}),
61+
},
62+
})
63+
64+
const zendeskProvider: Provider = {
65+
meta(params: MetaParams, settings: Settings): MetaResult {
66+
return { name: 'Zendesk', mentions: { label: 'Search by subject, id, or paste url...' } }
67+
},
68+
async mentions(params: MentionsParams, settings: Settings): Promise<MentionsResult> {
69+
checkSettings(settings)
70+
71+
return searchTickets(params.query, settings).then(items =>
72+
items.map(item => ({
73+
title: `#${item.id}`,
74+
uri: item.url,
75+
description: item.subject,
76+
data: { id: item.id },
77+
})),
78+
)
79+
},
80+
81+
async items(params: ItemsParams, settings: Settings): Promise<ItemsResult> {
82+
checkSettings(settings)
83+
84+
const id = (params.mention?.data as { id: number }).id
85+
86+
const ticket = await fetchTicket(id, settings)
87+
88+
if (!ticket) {
89+
return []
90+
}
91+
92+
return [ticketToItem(ticket)]
93+
},
94+
}
95+
96+
export default zendeskProvider

provider/zendesk/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@openctx/zendesk",
3+
"version": "0.0.1",
4+
"description": "Use information from Zendesk (OpenCtx provider)",
5+
"license": "Apache-2.0",
6+
"homepage": "https://openctx.org/docs/providers/zendesk",
7+
"repository": {
8+
"type": "git",
9+
"url": "https://github.com/sourcegraph/openctx",
10+
"directory": "provider/zendesk"
11+
},
12+
"type": "module",
13+
"main": "dist/bundle.js",
14+
"types": "dist/index.d.ts",
15+
"files": ["dist/bundle.js", "dist/index.d.ts"],
16+
"sideEffects": false,
17+
"scripts": {
18+
"bundle": "tsc --build && esbuild --log-level=error --bundle --format=esm --outfile=dist/bundle.js index.ts",
19+
"prepublishOnly": "tsc --build --clean && npm run --silent bundle",
20+
"test": "vitest"
21+
},
22+
"dependencies": {
23+
"@openctx/provider": "workspace:*"
24+
}
25+
}
26+

provider/zendesk/tsconfig.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "../../.config/tsconfig.base.json",
3+
"compilerOptions": {
4+
"rootDir": ".",
5+
"outDir": "dist",
6+
"lib": ["ESNext"]
7+
},
8+
"include": ["*.ts"],
9+
"exclude": ["dist", "vitest.config.ts"],
10+
"references": [{ "path": "../../lib/provider" }]
11+
}
12+

0 commit comments

Comments
 (0)