feat: initial commit
This commit is contained in:
parent
74beae7fc5
commit
a1d3ddb8dc
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
API_KEYS=asdf,hjkl
|
8
.prettierrc.cjs
Normal file
8
.prettierrc.cjs
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
tabWidth: 2,
|
||||
trailingComma: 'all',
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
arrowParens: 'avoid',
|
||||
plugins: [],
|
||||
}
|
20
env.js
Normal file
20
env.js
Normal 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
228
format.js
Normal 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```',
|
||||
}
|
||||
}
|
48
index.js
48
index.js
@ -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}`)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
101
plane.js
Normal 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
37
zulip.js
Normal 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),
|
||||
},
|
||||
}
|
Loading…
Reference in New Issue
Block a user