-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.js
383 lines (354 loc) · 12.6 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
import bolt from '@slack/bolt';
import {
createGithubIssueSlackThread,
createInstallation,
deleteInstallation,
getIssueThreadsFromIssue,
getInstallation,
countAllOpenIssues,
countAllOpenIssuesInChannel,
setIssueIsClosed,
allOpenIssueUrlsInChannel, getSlackBotToken,
} from "./db.js";
const { App, ExpressReceiver } = bolt;
import octokit from '@octokit/webhooks';
const { Webhooks, createNodeMiddleware } = octokit;
import minimist from 'minimist';
import * as StringArgv from 'string-argv';
import {getTeamId} from "./slack.js";
const {parseArgsStringToArgv} = StringArgv
import { Octokit } from 'octokit';
import {getIssueStatus, postCommentOnIssue, getLastComment} from "./github.js";
import * as Sentry from '@sentry/node';
// Setup Sentry
Sentry.init({
dsn: process.env.SENTRY_DSN,
});
// Github webhooks config
const githubWebhooks = new Webhooks({secret: process.env.GITHUB_WEBHOOKS_SECRET})
// Github REST API config
const octokitClient = new Octokit({
auth: process.env.GITHUB_ACCESS_TOKEN,
})
const expressReceiver = new ExpressReceiver({
signingSecret: process.env.SLACK_SIGNING_SECRET,
clientId: process.env.SLACK_CLIENT_ID,
clientSecret: process.env.SLACK_CLIENT_SECRET,
stateSecret: process.env.SLACK_STATE_SECRET,
scopes: [
"app_mentions:read",
"bookmarks:read",
"bookmarks:write",
"channels:history",
"channels:join",
"channels:read",
"chat:write",
"commands",
"users:read.email",
"users:read",
"users.profile:read",
],
installationStore: {
storeInstallation: createInstallation,
fetchInstallation: getInstallation,
deleteInstallation,
},
});
const app = new App({
receiver: expressReceiver,
logLevel: process.env.ENV === 'development' ? 'DEBUG' : 'INFO',
});
/**
* @param { string } issueUrl
* @returns { string }
*/
const issueIdFromUrl = (issueUrl) => {
return issueUrl.split('/').pop();
}
const renderIssueRef = (issueUrl) => {
const issueId = issueIdFromUrl(issueUrl);
return `<${issueUrl}|issue #${issueId}>`;
}
githubWebhooks.on('issues.assigned', async ({ payload}) => {
const issueUrl = payload.issue.html_url;
const slack_threads = await getIssueThreadsFromIssue(issueUrl);
const assignees = (payload.issue.assignees || [])
.filter(a => !!a)
.map(a => `<${a.html_url}|${a.login}>`).join(', ');
const text = `🥳 ${assignees} started work on ${renderIssueRef(issueUrl)}!`
for await (const slack_thread of slack_threads) {
await app.client.chat.postMessage({
text,
token: slack_thread.bot_token,
channel: slack_thread.channel_id,
thread_ts: slack_thread.slack_thread_ts,
unfurl_links: false,
unfurl_media: false,
})
}
})
githubWebhooks.on('issues.closed', async({ payload }) => {
const issueUrl = payload.issue.html_url;
await setIssueIsClosed(issueUrl, true);
let message = `Issue ${renderIssueRef(issueUrl)} was closed`; // default message
if (payload.issue.state_reason === 'completed') {
message = `✅ We've fixed ${renderIssueRef(issueUrl)}: _${payload.issue.title}_\n\nLightdash Cloud users will automatically get the fix once your instance updates (All instances update at 01:00 PST [10:00 CET] daily). Self-hosted users should update to the latest version to get the fix 🎉`
}
else if (payload.issue.state_reason === 'not_planned') {
const lastMessage = (await getLastComment(octokitClient, issueUrl)) || 'No information provided';
message = `🗑 Issue ${renderIssueRef(issueUrl)} is no longer planned to be fixed.\n> ${lastMessage}\nCheck out the linked issue for more information.`
}
const slack_threads = await getIssueThreadsFromIssue(issueUrl);
for await (const slack_thread of slack_threads) {
await app.client.chat.postMessage({
text: message,
token: slack_thread.bot_token,
channel: slack_thread.channel_id,
thread_ts: slack_thread.slack_thread_ts,
unfurl_links: false,
unfurl_media: false,
})
}
})
githubWebhooks.on('issues.reopened', async({payload}) => {
const issueUrl = payload.issue.html_url;
await setIssueIsClosed(issueUrl, false);
const slack_threads = await getIssueThreadsFromIssue(issueUrl);
for await (const slack_thread of slack_threads) {
await app.client.chat.postMessage({
text: `🔧 We've reopened this issue: ${renderIssueRef(issueUrl)}: _${payload.issue.title}_\n\nI'll notify everybody here as soon as there's another update.`,
token: slack_thread.bot_token,
channel: slack_thread.channel_id,
thread_ts: slack_thread.slack_thread_ts,
unfurl_links: false,
unfurl_media: false,
})
}
})
app.command(/\/cloudy(-dev)?/, async ({ command, ack, respond, client }) => {
await ack();
const args = minimist(parseArgsStringToArgv(command.text));
const showHelp = async () => {
await respond('Try:\n`/cloudy list all` to list all the issues being tracked\n`/cloudy list #channel-name` to list all issues tracked in a channel\n`/cloudy list https://github.com/lightdash/lightdash/issues/2222` to list all threads for this issue')
};
if ((args._ ||[]).includes('help')) {
await showHelp();
return;
}
if ((args._ || []).includes('list')) {
const arg = args._[args._.findIndex(v => v === 'list')+1];
if (arg && arg.startsWith('<http')) {
const issueUrl = arg.slice(1, -1)
const rows = await getIssueThreadsFromIssue(issueUrl);
if (rows.length === 0) {
await respond(`I can't find any slack threads linked to github issue: ${issueUrl}`);
return;
}
const promises = rows.map(row => client.chat.getPermalink({
token: row.bot_token,
channel: row.channel_id,
message_ts: row.slack_thread_ts})
);
const results = await Promise.all(promises);
const permalinks = results.filter(r => r.ok).map(r => r.permalink);
await respond(`I'm tracking that issue in these threads:${permalinks.map(l => `\n🧵 ${l}`)}`) ;
}
else if (arg && arg.startsWith('<#')) {
const channelRef = arg;
const channelId = arg.split('|')[0].slice(2)
const results = await countAllOpenIssuesInChannel(channelId);
if (results && results.length > 0) {
await respond(`I'm tracking these github issues in ${channelRef}:\n${results.map(row => `\n🐛 ${row.count === 1 ? '' : `*${row.count}x* `}${row.github_issue_url}`)}`);
}
else {
await respond(`I'm not tracking any issues in ${channelRef}`);
}
}
else if (arg && arg === 'all') {
const results = await countAllOpenIssues();
if (results && results.length > 0) {
await respond(`Here are all the issues I'm tracking:\n${results.map(row => `\n🐛 ${row.count === 1 ? '' : `*${row.count}x* `}${row.github_issue_url}`)}`);
}
else {
await respond(`I'm not tracking any issues yet!`);
}
}
else {
await showHelp();
}
return;
}
await showHelp();
})
const findLinks = blocks =>
blocks.flatMap(block =>
block.elements.flatMap(element =>
element.type === 'link'
? [element.url]
: element.elements
? findLinks([{elements: element.elements}])
: []
)
);
app.shortcut('link_issue', async ({shortcut, ack, client, say}) => {
await ack();
const links = findLinks(shortcut.message.blocks)
const githubLinkRegex = /https:\/\/github.com\/[^\/]+\/[^\/]+\/issues\/[0-9]+/
const githubLinks = links.filter(url => githubLinkRegex.exec(url));
const threadTs = shortcut.message.thread_ts || shortcut.message_ts;
const channelId = shortcut.channel.id;
const teamId = getTeamId(shortcut);
const slackBotToken = await getSlackBotToken(teamId);
const threadPermalink = await client.chat.getPermalink({
token: slackBotToken,
channel: channelId,
message_ts: threadTs,
});
for await (const githubLink of githubLinks) {
const issueStatus = await getIssueStatus(octokitClient, githubLink);
const isClosed = issueStatus === undefined ? undefined : issueStatus === 'closed';
try {
await createGithubIssueSlackThread(githubLink, teamId, channelId, threadTs, isClosed);
} catch (e) {
if ((e.constraint && e.constraint === 'github_issue_slack_threads_pkey')) {
// do nothing we already subscribed
} else {
throw e;
}
}
try {
await postCommentOnIssue(octokitClient, githubLink, `This issue was mentioned by a user in slack: ${threadPermalink.permalink}`);
} catch (e) {
if (e.status === 404) {
// do nothing, issue doesn't exist
} else {
// log out the error but don't crash the handler
console.error(e);
}
}
}
const joinAndSay = async (args) => {
try {
await say(args);
} catch (e) {
if (e.code === 'slack_webapi_platform_error' && e.data?.error === 'not_in_channel') {
await client.conversations.join({channel: channelId});
await say(args);
}
else {
throw e;
}
}
}
const githubLinksWithThreads = {};
for (const githubLink of githubLinks) {
const threads = await getIssueThreadsFromIssue(githubLink);
githubLinksWithThreads[githubLink] = threads;
}
if (githubLinks.length === 0) {
await joinAndSay({
text: `I couldn't find any github issue links in that message`,
thread_ts: threadTs,
});
} else if (githubLinks.length === 1) {
const [firstGithubLink] = githubLinks;
const threads = githubLinksWithThreads[firstGithubLink];
const totalRequests =
threads && threads.length > 0
? threads.length - 1
: 0;
const text =
totalRequests > 0
? `I've upvoted ${renderIssueRef(
firstGithubLink
)} for you! This issue has been requested by ${
totalRequests
} other users.\n\nI'm tracking it, so I'll notify everyone here as soon as it's fixed.`
: `I've upvoted ${renderIssueRef(
firstGithubLink
)} for you! You're the first user to request this issue.\n\nI'm tracking it, so I'll notify everyone here as soon as it's fixed.`;
await joinAndSay({
text: text,
thread_ts: threadTs,
unfurl_links: false,
unfurl_media: false,
});
} else {
let text = `I've upvoted these issues for you:\n`;
for (const githubLink of githubLinks) {
const threads = githubLinksWithThreads[githubLink];
const totalRequests =
threads && threads.length > 0
? threads.length - 1
: 0;
if (totalRequests > 0) {
text += `\n🛠️ ${renderIssueRef(
githubLink
)} - this issue has been requested by ${
totalRequests
} other users.`;
} else {
text += `\n🛠️ ${renderIssueRef(
githubLink
)} - you're the first user to request this issue.`;
}
}
await joinAndSay({
text: text,
thread_ts: threadTs,
unfurl_links: false,
unfurl_media: false,
});
}
/**
*
* @param {string}channelId
* @param {{key: string, value: string, link: string}[]}bookmarks
* @returns {Promise<void>}
*/
const setBookmarks = async (channelId, bookmarks) => {
const results = await client.bookmarks.list({channel_id: channelId});
if (!results.ok) {
return;
}
const existingBookmarks = results.bookmarks;
for await (const bookmark of bookmarks) {
const match = existingBookmarks.find(existing => existing.title.startsWith(bookmark.key));
if (match) {
await client.bookmarks.edit({
channel_id: match.channel_id,
bookmark_id: match.id,
link: bookmark.link,
title: `${bookmark.key} (${bookmark.value})`,
})
}
else {
await client.bookmarks.add({
channel_id: channelId,
title: `${bookmark.key} (${bookmark.value})`,
type: 'link',
link: bookmark.link,
})
}
}
}
const openIssueUrls = await allOpenIssueUrlsInChannel(channelId);
const totalIssues = openIssueUrls.length;
const issueIds = openIssueUrls.slice(0, 50).map(issueIdFromUrl); // GitHub url appears to only support 50 issue ids
const issueListHtmlUrl = `https://github.com/lightdash/lightdash/issues/?q=is%3Aissue+is%3Aopen+${issueIds.join('+')}`
await setBookmarks(channelId, [{
key: 'Open github issues',
value: totalIssues,
link: issueListHtmlUrl,
}]);
});
expressReceiver.app.use(createNodeMiddleware(githubWebhooks));
expressReceiver.app.get('/healthz', (_, res) => {
res.status(200).send();
})
Sentry.setupExpressErrorHandler(expressReceiver.app);
(async () => {
await app.start(3001);
console.log("Bolt app running on localhost:3001");
})()
.catch((e) => console.error(e));