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",
|
"module": "ESNext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"allowJs": true,
|
|
||||||
|
|
||||||
// Bundler mode
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
@ -19,9 +18,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
// Some stricter flags (disabled by default)
|
"allowJs": true,
|
||||||
"noUnusedLocals": false,
|
"checkJs": true
|
||||||
"noUnusedParameters": false,
|
|
||||||
"noPropertyAccessFromIndexSignature": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "zulip-plane",
|
"name": "zulip-plane",
|
||||||
"module": "index.js",
|
"module": "index.js",
|
||||||
"type": "module",
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"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