From a1d3ddb8dc0e5b243be3aab5f5262ed2be9ceea0 Mon Sep 17 00:00:00 2001 From: Orion Kindel Date: Tue, 23 Jul 2024 12:00:18 -0500 Subject: [PATCH] feat: initial commit --- .env.example | 1 + .prettierrc.cjs | 8 ++ bun.lockb | Bin 3167 -> 3527 bytes env.js | 20 +++++ format.js | 228 ++++++++++++++++++++++++++++++++++++++++++++++++ index.js | 48 +++++++++- jsconfig.json | 7 +- package.json | 5 +- plane.js | 101 +++++++++++++++++++++ zulip.js | 37 ++++++++ 10 files changed, 447 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 .prettierrc.cjs create mode 100644 env.js create mode 100644 format.js create mode 100644 plane.js create mode 100644 zulip.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..74eb288 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +API_KEYS=asdf,hjkl diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..b8e1fd1 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,8 @@ +module.exports = { + tabWidth: 2, + trailingComma: 'all', + singleQuote: true, + semi: false, + arrowParens: 'avoid', + plugins: [], +} diff --git a/bun.lockb b/bun.lockb index 91116d5b04767b10bcbb341f7fd83e443f16b4e7..49b3e2952f3b693408614380e4f78d6d9baf42ce 100755 GIT binary patch delta 860 zcmcaFaa?+Wp62G_1m$`cUkw?9Wi`9`9kzDfk&5$s$G_)yq3rGzQZMeB^D=+|$HZ{? zqzn!S2SRc)Ff;%$0~e4kC`v6U$xJO0h4P;OX-=THAtM6=NL?9_<^j?gKpG_O{VavC zq}U;1zG{Pxc57&QMmxr)$(Bs^jE5&zGFjKN0M&yq2q4oyD;ZdUm>aV+w6Xx79KFb^VHr#Xn?;gbt^J|$#^9^nYzUBF1 z!rpfN=fxFqYR~^Q>6@K@{4+`JZkaI9EsPBR`2PWE=E*mi!zXvJ@J_a6@n&S5+{qF? z*@BgK@>?K}eX=EMxd_NZuz*hovIT&FHaU=O%4Qcfb;ijK%!XVWp#D6iUplAUp1+hWOLBR^s z0-`5CwN!CUPU27lsh-@@#z_r^Rq5xGBMU!0QDFeFc>mU=HZl(fh9W5e4Ash0%z?8 zDlpYEG6R|lRN%lV!F2+dbU}cFdvX$|%j6ZD4qO%>mjHo2@8nOMz5Gd;dHOI{>m_GR c4&;`Rgz@!t;ZnNBdO$e2pIdG7Lau#`0E{}nmjD0& delta 623 zcmX>ueP3dNo)+)QO&hn)O}eo`=|FZ(x89V=Z|?cYH7%Fh5|MQ5(c^<3&T}(>0XxIQ z0I`XA96{Oa5CI4Ylx+a1-~!U3Pd7aWtW%kRT0s~DKs3-q z1{NS@0%A5OALcF)4Uz`|lJrfkWIi)_E{iuK^W>K-;gesm@J{w+^=4$9Jd-ti@(fnq z$$we1C(E!;*(|`W&d9X^YTps|$weIYQvd!#0LWI5i#|Z5IXEVtv2ig!J>#W-{#n>z*)P2 z3QYBk%=Aov3Lud?0BJFP1^@s6 diff --git a/env.js b/env.js new file mode 100644 index 0000000..ac11a3c --- /dev/null +++ b/env.js @@ -0,0 +1,20 @@ +/** @type {(k: string) => string} */ +const required = k => { + if (!process.env[k]) { + throw new Error(`Required environment variable not set: ${k}`) + } + return process.env[k] +} + +/** @type {(k: string) => Array} */ +const array = k => { + const vals = required(k) + .split(',') + .flatMap(s => (!s.trim() ? [] : [s.trim()])) + if (vals.length === 0) { + throw new Error(`At least 1 value required: ${k}`) + } + return vals +} + +export default { required, array } diff --git a/format.js b/format.js new file mode 100644 index 0000000..c37fc0e --- /dev/null +++ b/format.js @@ -0,0 +1,228 @@ +import Plane from './plane.js' + +/** + * @template Event + * @template Action + * @template EventData + * @template Field + * @template FieldData + * @typedef + * {{event: Event, action: Action, data: EventData, activity: {field: Field} & FieldData}} + * GenericEvent + */ + +/** + * @typedef + * {{ + "id": string, + "cycle": { + "id": string, + "name": string, + "start_date": string, + "end_date": string, + "created_by": string, + "updated_by": string, + "project": string, + "workspace": string, + "owned_by": string + }, + "labels": Array, + "assignees": Array, + "state": { + "id": string, + "name": string, + "color": string, + "group": import('./plane.js').StateGroup + }, + "estimate_point": number, + "name": string, + "sequence_id": number, + "description": unknown, + "description_html": string, + "description_stripped": string, + "priority": string, + "start_date": unknown, + "target_date": unknown, + "created_by": string, + "updated_by": string, + "project": string, + "workspace": string, + "parent": string | null + }} + * IssueEventData + */ + +/** @typedef {{ + "id": string, + "first_name": string, + "last_name": string, + "email": string, + "avatar": string, + "display_name": string + }} + EventActor + */ + +/** @typedef {GenericEvent<"issue", "created", IssueEventData, null, {actor: EventActor}>} EventIssueCreated */ + +/** @template Field @typedef {GenericEvent<"issue", "created", IssueEventData, Field, {actor: EventActor}>} EventIssueUpdated */ + +/** + * @typedef + * {EventIssueCreated | EventIssueUpdated<"description"> | GenericEvent} + * Event + */ + +/** @type {(e: Event) => e is GenericEvent<"issue", unknown, IssueEventData, unknown, {actor: EventActor}>} */ +export const isIssue = e => e.event === 'issue' + +/** @type {(e: Event) => e is EventIssueCreated} */ +export const isIssueCreated = e => + e.event === 'issue' && e.action === 'created' && e.activity.field === null + +/** @type {(e: Event) => e is EventIssueUpdated<"description">} */ +export const isIssueDescriptionUpdated = e => + e.event === 'issue' && + typeof e.action === 'string' && + ['created', 'updated'].includes(e.action) && + e.activity.field === 'description' + +/** @type {(e: Event) => e is EventIssueUpdated<"estimate_point">} */ +export const isIssueEstimateUpdated = e => + e.event === 'issue' && + typeof e.action === 'string' && + ['created', 'updated'].includes(e.action) && + e.activity.field === 'estimate_point' + +/** @type {(e: Event) => e is EventIssueUpdated<"state">} */ +export const isIssueStateUpdated = e => + e.event === 'issue' && e.action === 'updated' && e.activity.field === 'state' + +/** @type {(c: import('./plane.js').Config, p: string, i: string) => string} */ +export const issueURL = (c, project, issue) => + c.baseURI + '/' + c.workspace + '/projects/' + project + '/issues/' + issue + +/** @type {(plane: import('./plane.js').Config, e: Event) => Promise<{content: string, topic: string}>} */ +export default async (plane, ev) => { + if (isIssue(ev)) { + const user = `\`${ev.activity.actor.display_name}\`` + const description = ev.data.description_stripped.trim() + ? `> ${ev.data.description_stripped.trim()}` + : '' + + const parent = await (async () => { + if (!ev.data.parent) { + return null + } + return Plane.Issue.get(plane, ev.data.project, ev.data.parent) + })() + + const subIssues = await (async () => { + const id = (parent || ev.data).id + const allIssues = await Plane.Issue.getAll(plane, ev.data.project) + return allIssues.filter(i => i.parent === id) + })() + + /** @type {(_: {sequence_id: number, name: string, project: string, id: string}) => string} */ + const link = ({ sequence_id, name, project, id }) => + `**[ATH-${sequence_id}](${issueURL(plane, project, id)}) ${name}**` + const thisLink = link(ev.data) + const title = parent ? link(parent) + '\n⤷ ' + link(ev.data) : link(ev.data) + const topic = parent + ? 'ATH-' + parent.sequence_id + : 'ATH-' + ev.data.sequence_id + + /** @type {(g: import('./plane.js').StateGroup) => string} */ + const stateEmoji = s => + s === 'completed' + ? ':check:' + : s === 'started' + ? ':yellow_large_square:' + : ':white_large_square:' + + const state = `${stateEmoji(ev.data.state.group)} ${ev.data.state.name}` + + const subIssueSummary = await (async () => { + const states = await Plane.State.getAll(plane, ev.data.project) + /** @type {Record}*/ + const stateMap = {} + states.forEach(s => { + stateMap[s.id] = s + }) + + /** @type {(s: import('./plane.js').StateGroup) => Array} */ + const by_state = state => + subIssues.flatMap(i => { + const s = stateMap[i.state] + if (!s) return [] + if (s.group !== state) return [] + if (i.id === ev.data.id) + return [{ ...ev.data, state: ev.data.state.id }] + return [i] + }) + + return ( + '**Sub-issues**\n' + + by_state('completed') + .map(i => stateEmoji(stateMap[i.state].group) + ' ' + link(i)) + .concat( + by_state('started').map( + i => stateEmoji(stateMap[i.state].group) + ' ' + link(i), + ), + ) + .concat( + by_state('unstarted').map( + i => stateEmoji(stateMap[i.state].group) + ' ' + link(i), + ), + ) + .concat( + by_state('backlog').map( + i => stateEmoji(stateMap[i.state].group) + ' ' + link(i), + ), + ) + .map(l => ' - ' + l) + .join('\n') + ) + })() + + /** @type {Record} */ + const estimateMap = { + 0: '**XS** (Extra Small)', + 1: '**S** (Small)', + 2: '**M** (Medium)', + 3: '**L** (Large)', + 4: '**XL** (Extra Large)', + } + + if (isIssueCreated(ev) && !!parent) { + return { + topic, + content: `${title}\n${user} created sub-issue ${thisLink}\n${subIssueSummary}`, + } + } else if (isIssueCreated(ev)) { + return { topic, content: `${title}\n${user} created issue` } + } else if (isIssueEstimateUpdated(ev)) { + return { + topic, + content: `${title}\n${user} set estimate to ${estimateMap[ev.data.estimate_point.toString()]}`, + } + } else if (isIssueDescriptionUpdated(ev)) { + return { + topic, + content: `${title}\n${user} updated description\n${description}`, + } + } else if (isIssueStateUpdated(ev) && !!parent) { + return { + topic, + content: `${title}\n${user} changed ${thisLink}'s state to ${state}\n${subIssueSummary}`, + } + } else if (isIssueStateUpdated(ev)) { + return { topic, content: `${title}\n${user} changed state to ${state}` } + } + } + + return { + topic: 'unknown', + content: '```json\n' + JSON.stringify(ev, null, 2) + '\n```', + } +} diff --git a/index.js b/index.js index f67b2c6..99f2e19 100644 --- a/index.js +++ b/index.js @@ -1 +1,47 @@ -console.log("Hello via Bun!"); \ No newline at end of file +import Zulip from './zulip.js' +import Path from 'path' +import FS from 'fs/promises' +import formatPlaneEvent from './format.js' +import Env from './env.js' + +const API_KEYS = Env.array('API_KEYS') + +/** @type {import('./zulip.js').Config} */ +const zulip = { + email: Env.required('ZULIP_EMAIL'), + apiKey: Env.required('ZULIP_API_KEY'), + baseURI: Env.required('ZULIP_BASE_URI'), +} + +/** @type {import('./plane.js').Config} */ +const plane = { + workspace: Env.required('PLANE_WORKSPACE'), + apiKey: Env.required('PLANE_API_KEY'), + baseURI: Env.required('PLANE_BASE_URI'), +} + +const server = Bun.serve({ + fetch: async req => { + const url = new URL(req.url) + const apiKey = url.searchParams.get('apiKey') + + if (!apiKey) return new Response(null, { status: 401 }) + if (!API_KEYS.includes(apiKey.trim())) + return new Response(null, { status: 401 }) + + const body = await req.json() + const { topic, content } = await formatPlaneEvent(plane, body) + + await Zulip.Messages.send(zulip, { + type: 'stream', + to: 'plane', + topic, + content, + }) + + return new Response() + }, +}) + +console.log(`Zulip <> Plane integration`) +console.log(`listening on ${server.hostname}:${server.port}`) diff --git a/jsconfig.json b/jsconfig.json index 238655f..1668f05 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -6,7 +6,6 @@ "module": "ESNext", "moduleDetection": "force", "jsx": "react-jsx", - "allowJs": true, // Bundler mode "moduleResolution": "bundler", @@ -19,9 +18,7 @@ "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "allowJs": true, + "checkJs": true } } diff --git a/package.json b/package.json index 7301310..15b8fee 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "zulip-plane", "module": "index.js", - "type": "module", "devDependencies": { "@types/bun": "latest", + "prettier": "^3.3.3", "typescript": "^5.5.4" }, "peerDependencies": { "typescript": "^5.0.0" - } + }, + "type": "module" } \ No newline at end of file diff --git a/plane.js b/plane.js new file mode 100644 index 0000000..0f4caf0 --- /dev/null +++ b/plane.js @@ -0,0 +1,101 @@ +/** @typedef {{workspace: string, apiKey: string, baseURI: string}} Config */ + +/** @type {(c: Config, url: string) => Promise} */ +export const get = async (config, url) => { + const rep = await fetch(config.baseURI + url, { + method: 'GET', + headers: { + 'x-api-key': config.apiKey, + }, + }) + + if (!rep.ok) { + let m = `Status not OK: ${rep.status}` + try { + m += '\n' + (await rep.text()) + } catch {} + + throw new Error(m) + } + + return rep.json() +} + +/** @typedef {{ + "id": string, + "estimate_point": unknown, + "name": string, + "description_html": string, + "description_stripped": string, + "priority": string, + "sequence_id": number, + "project": string, + "workspace": string, + "parent": string | null, + "state": string, + "assignees": Array, + "labels": Array +}} Issue*/ + +/** @typedef {"backlog" | "unstarted" | "started" | "completed" | "cancelled"} StateGroup */ + +/** @typedef {{ + "id": string, + "created_at": string, + "updated_at": string, + "name": string, + "description": string, + "color": string, + "slug": string, + "group": StateGroup, + "default": boolean, + "project": string, + "workspace": string +}} State */ + +/** + * @template T + * @typedef + * {{results: Array, next_page_results: boolean, next_cursor: string}} + * Page + */ + +/** @type {(c: Config, u: string) => Promise>} */ +const paginated = async (config, u) => { + let get_next_page = true + let cursor = '' + const results = [] + while (get_next_page) { + /** @type {Page} */ + const rep = await get(config, u + `?cursor=${cursor}`) + get_next_page = rep.next_page_results + cursor = rep.next_cursor + results.push(...rep.results) + } + return results +} + +export default { + State: { + /** @type {(c: Config, p: string) => Promise>} */ + getAll: (config, project) => + paginated( + config, + `/api/v1/workspaces/${config.workspace}/projects/${project}/states`, + ), + }, + Issue: { + /** @type {(c: Config, p: string, i: string) => Promise} */ + get: (config, project, issue) => + get( + config, + `/api/v1/workspaces/${config.workspace}/projects/${project}/issues/${issue}`, + ), + /** @type {(c: Config, p: string) => Promise>} */ + getAll: async (config, project) => + paginated( + config, + `/api/v1/workspaces/${config.workspace}/projects/${project}/issues`, + ), + }, +} diff --git a/zulip.js b/zulip.js new file mode 100644 index 0000000..fa5e74f --- /dev/null +++ b/zulip.js @@ -0,0 +1,37 @@ +/** @typedef {{email: string, apiKey: string, baseURI: string}} Config */ + +/** @type {(c: Config, url: string, body: Record) => Promise} */ +export const post = async (config, url, body) => { + const params = new URLSearchParams() + Object.entries(body).forEach(([k, v]) => { + params.append(k, typeof v === 'string' ? v : JSON.stringify(v)) + }) + + const rep = await fetch(config.baseURI + url, { + method: 'POST', + body: params, + headers: { + Authorization: + 'Basic ' + + Buffer.from(`${config.email}:${config.apiKey}`, 'utf8').toString( + 'base64url', + ), + }, + }) + + if (!rep.ok) { + let m = `Status not OK: ${rep.status}` + try { + m += '\n' + (await rep.text()) + } catch {} + + throw new Error(m) + } +} + +export default { + Messages: { + /** @type {(c: Config, body: {type: string, to: string, content: string, topic?: string}) => Promise} */ + send: async (config, b) => post(config, '/messages', b), + }, +}