feat: initial commit

This commit is contained in:
orion 2024-07-23 12:00:18 -05:00
parent 74beae7fc5
commit a1d3ddb8dc
Signed by: orion
GPG Key ID: 6D4165AE4C928719
10 changed files with 447 additions and 8 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
API_KEYS=asdf,hjkl

8
.prettierrc.cjs Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
tabWidth: 2,
trailingComma: 'all',
singleQuote: true,
semi: false,
arrowParens: 'avoid',
plugins: [],
}

BIN
bun.lockb

Binary file not shown.

20
env.js Normal file
View File

@ -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<string>} */
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 }

228
format.js Normal file
View File

@ -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<unknown>,
"assignees": Array<string>,
"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<unknown, unknown, unknown, unknown, unknown>}
* 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<string, import('./plane.js').State>}*/
const stateMap = {}
states.forEach(s => {
stateMap[s.id] = s
})
/** @type {(s: import('./plane.js').StateGroup) => Array<import('./plane.js').Issue>} */
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<string, string>} */
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```',
}
}

View File

@ -1 +1,47 @@
console.log("Hello via Bun!");
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}`)

View File

@ -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
}
}

View File

@ -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"
}

101
plane.js Normal file
View File

@ -0,0 +1,101 @@
/** @typedef {{workspace: string, apiKey: string, baseURI: string}} Config */
/** @type {<T>(c: Config, url: string) => Promise<T>} */
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<string>,
"labels": Array<unknown>
}} 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<T>, next_page_results: boolean, next_cursor: string}}
* Page
*/
/** @type {<T>(c: Config, u: string) => Promise<Array<T>>} */
const paginated = async (config, u) => {
let get_next_page = true
let cursor = ''
const results = []
while (get_next_page) {
/** @type {Page<any>} */
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<Array<State>>} */
getAll: (config, project) =>
paginated(
config,
`/api/v1/workspaces/${config.workspace}/projects/${project}/states`,
),
},
Issue: {
/** @type {(c: Config, p: string, i: string) => Promise<Issue>} */
get: (config, project, issue) =>
get(
config,
`/api/v1/workspaces/${config.workspace}/projects/${project}/issues/${issue}`,
),
/** @type {(c: Config, p: string) => Promise<Array<Issue>>} */
getAll: async (config, project) =>
paginated(
config,
`/api/v1/workspaces/${config.workspace}/projects/${project}/issues`,
),
},
}

37
zulip.js Normal file
View File

@ -0,0 +1,37 @@
/** @typedef {{email: string, apiKey: string, baseURI: string}} Config */
/** @type {(c: Config, url: string, body: Record<string, unknown>) => Promise<void>} */
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<void>} */
send: async (config, b) => post(config, '/messages', b),
},
}