From 73ad04a8942548c1d2058a64a83a0b88e73683ee Mon Sep 17 00:00:00 2001 From: xHyroM Date: Wed, 13 Jul 2022 15:41:41 +0200 Subject: [PATCH] feat: autocomplete for github cmd --- bun.lockb | Bin 2295 -> 6234 bytes files/config.example.toml | 3 +- package.json | 2 + src/commands/github.ts | 49 +++-- src/index.ts | 70 ++++++- .../contexts/AutocompleteContext.ts | 6 +- src/utils/githubUtils.ts | 175 ++++++++++++++++++ src/utils/registerCommands.ts | 2 +- 8 files changed, 275 insertions(+), 32 deletions(-) create mode 100644 src/utils/githubUtils.ts diff --git a/bun.lockb b/bun.lockb index 67f9c491b8c90070c9fd1e7bde87ff7088bfb104..6ba2f04b6027b5cf107f2df03c15cddfad89d0c7 100755 GIT binary patch literal 6234 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 delta 766 zcmca*@Lh0%p62$@Pk}bvo)_Ml@h!depL2H@xAtkZisP5!LvOmddR*v==U@N>wu#~L z>JqFF4us@qU}&%dGC6^CMt)vCNK_Yy1t+sIs_^Hk?$=!F6=E_;-MGv1@|?*F7$r?~ z*}(=ZW{}KjWny6X5&t^*Pd!K*1DM~D0O2!&g%}u^VuAe0Zy8Nkz>HWX69EtdqJu#N zsETp2A(I9~HkTEuY$>aGD99R^TM5x1%ZO4xnUVd>WK|CD$r2pAlWRG=8CfR38_3ZN3|uxLMX9NF3PuJB#glFM#rYWiLjg#Z zb#fyAc1%sq-~;ktQ38`#0Cm9@w#imPN|Rp* z3h;p}c>opu0v47734!ck24aw@AX7Bhfdmk&VxRm;NDU-2SzOq1a+a`x4lFU9^y5%E za7sXei80Pl&p^+Zf#D%k6O87vnY>X- drfx}PL27YHd1`7&USe|2 = new Collection(); const invalidIssue = (ctx: CommandContext, query: string) => { @@ -25,7 +22,10 @@ new Command({ name: 'query', description: 'Issue numer/name, PR number/name or direct link to Github Issue or PR', type: ApplicationCommandOptionType.String, - required: true + required: true, + run: (ctx) => { + return ctx.respond(search(ctx.value, ctx?.options?.[1]?.value as string || 'oven-sh/bun')); + } }, { name: 'repository', @@ -64,35 +64,32 @@ new Command({ const repositoryOwner = repositorySplit[0]; const repositoryName = repositorySplit[1]; - const isIssueOrPR = githubIssuesAndPullRequests(repositoryOwner, repositoryName).test(query); - const isIssueOrPRNumber = isNumeric(query); - - cooldowns.set(ctx.user.id, Date.now() + 30000); - if (!isIssueOrPR && !isIssueOrPRNumber) { + let issueOrPR = getIssueOrPR(parseInt(query), repository); + if (!issueOrPR) { const res = await fetch(`https://api.github.com/search/issues?q=${encodeURIComponent(query)}${encodeURIComponent(' repo:oven-sh/bun')}`); const data: any = await res.json(); if (data.message || data?.items?.length === 0) return invalidIssue(ctx, query); - query = data.items[0].number; + const item = data.items[0]; + issueOrPR = { + id: item.number, + repository: item.repository_url.replace('https://api.github.com/repos/', ''), + title: item.title, + number: item.number, + state: item.state, + created_at: item.created_at, + closed_at: item.closed_at, + html_url: item.html_url, + user_login: item.user.login, + user_html_url: item.user.html_url, + type: item.pull_request ? '(PR)' : '(ISSUE)', + }; } - const issueUrl = `https://api.github.com/repos/${repositoryOwner}/${repositoryName}/issues/${isIssueOrPR ? query.split('/issues/')[1] : query}`; - - const res = await fetch(issueUrl, { - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'bun-discord-bot', - 'Authorization': config.api.github_personal_access_token - } - }); - - const data: any = await res.json(); - if (data.message) return invalidIssue(ctx, query); - return ctx.editResponse([ - `[#${data.number} ${repositoryOwner}/${repositoryName}](<${data.html_url}>) by [${data.user.login}](<${data.user.html_url}>) ${formatStatus(data)}`, - data.title + `[#${issueOrPR.number} ${repositoryOwner}/${repositoryName}](<${issueOrPR.html_url}>) by [${issueOrPR.user_login}](<${issueOrPR.user_html_url}>) ${formatStatus(issueOrPR)}`, + issueOrPR.title ].join('\n')); } }) \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7afef2f..35bd1e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,11 @@ import { Commands } from './managers/CommandManager'; import registerCommands from './utils/registerCommands'; import { Option, OptionOptions } from './structures/Option'; import { AutocompleteContext } from './structures/contexts/AutocompleteContext'; +import { deleteIssue, deletePullRequest, fetchIssues, fetchPullRequests, issues, setIssue, setPullRequest } from './utils/githubUtils'; +import createHmac from 'create-hmac'; +await fetchIssues(); +await fetchPullRequests(); await loadCommands(); try { await registerCommands(config.client.token, config.client.id); @@ -21,7 +25,7 @@ try { } const app = new Hono(); -app.get('*', (c) => c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME')); +app.get('*', (c) => c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME')); // fireship :D app.post('/interaction', bodyParse(), async(c) => { const signature = c.req.headers.get('X-Signature-Ed25519'); @@ -56,7 +60,8 @@ app.post('/interaction', bodyParse(), async(c) => { return option.run(new AutocompleteContext( c, option, - focused.value + focused.value, + interaction.data.options as any )); } @@ -77,6 +82,67 @@ app.post('/interaction', bodyParse(), async(c) => { }); }) +app.post('/github_webhook', bodyParse(), (c) => { + if ( + !c.req.headers.get('User-Agent').startsWith('GitHub-Hookshot/') || + typeof c.req.parsedBody !== 'object' + ) return c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'); // fireship :D + + const githubWebhooksSecret = new TextEncoder().encode(config.api.github_webhooks_secret); + const sha256 = `sha256=${createHmac('sha256', githubWebhooksSecret).update(JSON.stringify(c.req.parsedBody)).digest('hex')}`; + if (sha256 !== c.req.headers.get('X-Hub-Signature-256')) return c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'); // fireship :D + + const issueOrPr = c.req.parsedBody; + if (issueOrPr.action !== 'deleted') { + if (issueOrPr.issue) { + console.log('issue'); + setIssue({ + id: issueOrPr.issue.number, + repository: issueOrPr.issue.repository_url.replace('https://api.github.com/repos/', ''), + title: issueOrPr.issue.title, + number: issueOrPr.issue.number, + state: issueOrPr.issue.state, + created_at: new Date(issueOrPr.issue.created_at), + closed_at: new Date(issueOrPr.issue.closed_at), + html_url: issueOrPr.issue.html_url, + user_login: issueOrPr.issue.user.login, + user_html_url: issueOrPr.issue.user.html_url, + type: '(ISSUE)', + }) + } + else { + setPullRequest({ + id: issueOrPr.pull_request.number, + repository: issueOrPr.pull_request.html_url.replace('https://github.com/', '').replace(`/pull/${issueOrPr.pull_request.number}`, ''), + title: issueOrPr.pull_request.title, + number: issueOrPr.pull_request.number, + state: issueOrPr.pull_request.state, + created_at: new Date(issueOrPr.pull_request.created_at), + closed_at: new Date(issueOrPr.pull_request.closed_at), + merged_at: new Date(issueOrPr.pull_request.merged_at), + html_url: issueOrPr.pull_request.html_url, + user_login: issueOrPr.pull_request.user.login, + user_html_url: issueOrPr.pull_request.user.html_url, + type: '(PR)', + }) + } + } else { + if (issueOrPr.issue) deleteIssue( + issueOrPr.issue.number, issueOrPr.issue.repository_url.replace('https://api.github.com/repos/', '') + ); + else deletePullRequest( + issueOrPr.pull_request.number, + issueOrPr.pull_request.html_url + .replace('https://github.com/', '') + .replace(`/pull/${issueOrPr.pull_request.number}`, '') + ) + } + + return c.json({ + message: 'OK' + }, 200); +}) + await Bun.serve({ port: config.server.port, fetch: app.fetch, diff --git a/src/structures/contexts/AutocompleteContext.ts b/src/structures/contexts/AutocompleteContext.ts index 1659ace..d500402 100644 --- a/src/structures/contexts/AutocompleteContext.ts +++ b/src/structures/contexts/AutocompleteContext.ts @@ -1,4 +1,4 @@ -import { APIApplicationCommandOptionChoice, InteractionResponseType } from 'discord-api-types/v10'; +import { APIApplicationCommandInteractionDataBasicOption, APIApplicationCommandOptionChoice, InteractionResponseType } from 'discord-api-types/v10'; import { Context } from 'hono'; import { Option, OptionOptions } from '../Option'; @@ -6,11 +6,13 @@ export class AutocompleteContext { public context: Context; public option?: Option | OptionOptions; public value?: string; + public options?: APIApplicationCommandInteractionDataBasicOption[]; - public constructor(c: Context, option: Option | OptionOptions, value: string) { + public constructor(c: Context, option: Option | OptionOptions, value: string, options: APIApplicationCommandInteractionDataBasicOption[]) { this.context = c; this.option = option; this.value = value; + this.options = options; } public respond(response: APIApplicationCommandOptionChoice[]) { diff --git a/src/utils/githubUtils.ts b/src/utils/githubUtils.ts new file mode 100644 index 0000000..5c289d9 --- /dev/null +++ b/src/utils/githubUtils.ts @@ -0,0 +1,175 @@ +// @ts-expect-error Types :( +import config from '../../files/config.toml'; +// @ts-expect-error Types :( +import utilities from '../../files/utilities.toml'; +import MiniSearch from 'minisearch'; +import { Logger } from './Logger'; +import { APIApplicationCommandOptionChoice } from 'discord-api-types/v10'; + +interface Issue { + id: number; + repository: string; + title: string; + number: number; + state: 'open' | 'closed', + created_at: Date; + closed_at: Date | null; + html_url: string; + user_login: string; + user_html_url: string; + type: '(ISSUE)' | '(PR)'; +} + +interface PullRequest extends Issue { + merged_at: Date | null; +} + +export let issues: Issue[] = []; +export let pulls: PullRequest[] = []; + +export const fetchIssues = async() => { + for await (const repository of utilities.github.repositories) { + let page = 1; + + while (true) { + const res = await (await fetch(`https://api.github.com/repos/${repository}/issues?per_page=100&page=${page}&state=all`, { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'bun-discord-bot', + 'Authorization': `token ${config.api.github_personal_access_token}` + } + })).json() as any; + + for (const issue of res) { + if ('pull_request' in issue) continue; + + issues.push({ + id: issue.number, + repository: issue.repository_url.replace('https://api.github.com/repos/', ''), + title: issue.title, + number: issue.number, + state: issue.state, + created_at: new Date(issue.created_at), + closed_at: new Date(issue.closed_at), + html_url: issue.html_url, + user_login: issue.user.login, + user_html_url: issue.user.html_url, + type: '(ISSUE)' + }) + } + + Logger.debug(`Fetching issues for ${repository} - ${issues.length} * ${page}`); + + page++; + if (res.length === 0) { + break; + } + } + + Logger.success(`Issues have been fetched for ${repository} - ${issues.length}`); + } +} + +export const fetchPullRequests = async() => { + for await (const repository of utilities.github.repositories) { + let page = 1; + + while (true) { + const res = await (await fetch(`https://api.github.com/repos/${repository}/pulls?per_page=100&page=${page}&state=all`, { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'bun-discord-bot', + 'Authorization': `token ${config.api.github_personal_access_token}` + } + })).json() as any; + + for (const pull of res) { + pulls.push({ + id: pull.number, + repository: pull.html_url.replace('https://github.com/', '').replace(`/pull/${pull.number}`, ''), + title: pull.title, + number: pull.number, + state: pull.state, + created_at: new Date(pull.created_at), + closed_at: new Date(pull.closed_at), + merged_at: new Date(pull.merged_at), + html_url: pull.html_url, + user_login: pull.user.login, + user_html_url: pull.user.html_url, + type: '(PR)', + }) + } + + Logger.debug(`Fetching pull requests for ${repository} - ${pulls.length} * ${page}`); + + page++; + if (res.length === 0) { + break; + } + } + + Logger.success(`Pull requests have been fetched for ${repository} - ${pulls.length}`); + } +} + +export const setIssue = (issue: Issue) => { + const exists = issues.findIndex(i => i.number === issue.number && i.repository === issue.repository); + if (exists >= 0) issues[exists] = issue; + else issues.push(issue); +} + +export const setPullRequest = (pull: PullRequest) => { + const exists = pulls.findIndex(i => i.number === pull.number); + if (exists >= 0) pulls[exists] = pull; + else pulls.push(pull); +} + +export const deleteIssue = (number: number, repository: string) => { + issues = issues.filter(i => i.number === number && i.repository === repository); +} + +export const deletePullRequest = (number: number, repository: string) => { + pulls = pulls.filter(p => p.number === number && p.repository === repository); +} + +export const search = (query: string, repository: string): APIApplicationCommandOptionChoice[] => { + try { + const pullsFiltered = pulls.filter(pull => pull.repository === repository); + const issuesFiltered = issues.filter(issue => issue.repository === repository); + + if (!query) { + const array = [].concat(pullsFiltered.slice(0, 13), issuesFiltered.slice(0, 12)); + return array.map((issueOrPr: Issue | PullRequest) => new Object({ + name: `${issueOrPr.type} ${issueOrPr.title.slice(0, 93)}`, + value: issueOrPr.number.toString() + })) as APIApplicationCommandOptionChoice[] + } + + const array = [].concat(pullsFiltered, issuesFiltered); + + const searcher = new MiniSearch({ + fields: ['title', 'number', 'type'], + storeFields: ['title', 'number', 'type'], + searchOptions: { + fuzzy: 3, + processTerm: term => term.toLowerCase(), + }, + }); + + searcher.addAll(array); + + const result = searcher.search(query); + + return (result as unknown as Issue[] | PullRequest[]).slice(0, 25).map((issueOrPr: Issue | PullRequest) => new Object({ + name: `${issueOrPr.type} ${issueOrPr.title.slice(0, 93).replace(/[^a-z0-9 ]/gi, '')}`, + value: issueOrPr.number.toString() + })) as APIApplicationCommandOptionChoice[] + } catch(e) { + return []; + } +} + +export const getIssueOrPR = (number: number, repository: string): Issue | PullRequest => { + return issues.find(issue => issue.number === number && issue.repository === repository) || + pulls.find(pull => pull.number === number && pull.repository === repository); +} \ No newline at end of file diff --git a/src/utils/registerCommands.ts b/src/utils/registerCommands.ts index 360ec73..c86d94d 100644 --- a/src/utils/registerCommands.ts +++ b/src/utils/registerCommands.ts @@ -21,7 +21,7 @@ const sync = async( } ) - if (res.ok) return Logger.success('🌍 All commands has been synchronized with discord api.'); + if (res.ok) return Logger.success('🌍 All commands have been synchronized with discord api.'); const data = await res.json() as any; if (res.status === 429) {