feat: github command

This commit is contained in:
Jozef Steinhübl 2023-08-25 21:55:44 +02:00
parent 49a1492d40
commit c7cffecfab
6 changed files with 289 additions and 12 deletions

View file

@ -1,6 +1,279 @@
import { SlashCommandBooleanOption, SlashCommandStringOption, time } from "discord.js";
import { defineCommand } from "../loaders/commands.ts";
import { AutocompleteContext } from "../structs/context/AutocompleteContext.ts";
import { InteractionCommandContext } from "../structs/context/CommandContext.ts";
import { safeSlice, silently } from "../util.ts";
type State = "open" | "closed_as_completed" | "closed_as_not_planned" | "closed" | "merged" | "draft" | "all";
type StateEmoji = "🔴" | "🟠" | "🟢" | "⚫️" | "⚪️" | "🟣" | "📝";
type Type = "issues" | "pull_requests" | "both";
interface Item {
html_url: string;
number: number;
title: string;
user: {
html_url: string;
login: string;
}
emoji: {
state: StateEmoji;
type: string;
};
created_at: Date | null;
closed_at: Date | null;
pull_request?: {
merged_at: Date | null;
}
}
defineCommand({
name: "github",
description: "Search on github"
})
description: "Query an issue, pull request or direct link to issue, pull request",
options: [
{
...new SlashCommandStringOption()
.setName("query")
.setDescription("Issue/Pull request number or name")
.setRequired(true)
.setAutocomplete(true)
.setMaxLength(100),
run: async(ctx: AutocompleteContext) => {
const query = ctx.options.getString("query");
const state: State = ctx.options.getString("state") as State || "all";
const type: Type = ctx.options.getString("type") as Type || "both";
const response = await search(query, state, type, 25);
await silently(ctx.respond(response.map(r => ({
name: safeSlice<string>(`${r.emoji.type} ${r.emoji.state} #${r.number} | ${r.title}`, 100),
value: r.number.toString()
}))));
}
},
{
...new SlashCommandStringOption()
.setName("state")
.setDescription("Issue or Pull request state")
.setRequired(false)
.addChoices(
{
name: "🔴🟠 Open",
value: "open"
},
{
name: "🟢 Closed as completed",
value: "closed_as_completed"
},
{
name: "⚪️ Closed as not planned",
value: "closed_as_not_planned"
},
{
name: "⚫️ Closed",
value: "closed"
},
{
name: "🟣 Merged",
value: "merged"
},
{
name: "📝 Draft",
value: "draft",
},
{
name: "🌍 All",
value: "all",
}
)
},
{
...new SlashCommandStringOption()
.setName("type")
.setDescription("Issue or Pull Requests")
.setRequired(false)
.addChoices(
{
name: "🐛 Issues",
value: "issues"
},
{
name: "🔨 Pull Requests",
value: "pull_requests"
},
{
name: "🌍 Both",
value: "both"
}
)
},
{
...new SlashCommandBooleanOption()
.setName("hide")
.setDescription("Show this message only for you")
.setRequired(false)
}
],
run: async(ctx: InteractionCommandContext) => {
const hide = ctx.interaction.options.getBoolean("hide") ?? false;
await ctx.interaction.deferReply({
ephemeral: hide
});
const query = ctx.interaction.options.getString("query");
const state: State = ctx.interaction.options.getString("state") as State || "all";
const type: Type = ctx.interaction.options.getString("type") as Type || "both";
const result = (await search(query, state, type))[0];
if (!result) {
ctx.interaction.editReply({
content: `❌ Couldn't find issue or pull request \`${query}\``
});
return;
}
ctx.interaction.editReply({
content: [
`${result.emoji.type} ${result.emoji.state} [#${result.number} in oven-sh/bun](<${result.html_url}>) by [${result.user.login}](<${result.user.html_url}>) ${stateToText(result)} ${stateToTimestamp(result)}`,
result.title
].join("\n")
})
}
});
function stateToText(item: Item) {
switch (item.emoji.state) {
case "🔴":
case "🟠":
case "📝": {
return "opened";
}
case "🟢": {
return "closed as completed";
}
case "⚪️": {
return "closed as not planned";
}
case "⚫️": {
return "closed";
}
case "🟣": {
return "merged";
}
}
}
function stateToTimestamp(item: Item) {
let timestamp: Date;
switch (item.emoji.state) {
case "🔴":
case "🟠":
case "📝": {
timestamp = item.created_at;
break;
}
case "🟢":
case "⚪️":
case "⚫️": {
timestamp = item.closed_at;
break;
}
case "🟣": {
timestamp = item.pull_request.merged_at;
break;
}
}
return `<t:${Math.round(timestamp.getTime() / 1000)}:R>`;
}
async function search(query: string, state: State, type: Type, length = 1): Promise<Item[]> {
let actualQuery = "repo:oven-sh/bun ";
switch (state) {
case "open": {
actualQuery += "state:open ";
break;
}
case "closed": {
actualQuery += "state:closed ";
break;
}
case "closed_as_completed": {
actualQuery += "state:closed reason:completed "
break;
}
case "closed_as_not_planned": {
actualQuery += "state:closed reason:\"not planned\" ";
break;
}
case "merged": {
actualQuery += "is:merged ";
break;
}
case "draft": {
actualQuery += "draft:true ";
break;
}
}
switch (type) {
case "issues": {
actualQuery += "type:issue "
break;
}
case "pull_requests": {
actualQuery += "type:pr "
break;
}
}
// append user query + remove all tags
actualQuery += query.replace(/\S+:\S+/g, "").trim();
const response = await fetch(`https://api.github.com/search/issues?q=${encodeURIComponent(actualQuery)}&per_page=${length}`, {
headers: {
"Authorization": `Bearer ${process.env.GITHUB_TOKEN}`,
"Accept": "application/vnd.github+json"
}
});
const body = await response.json();
const items = body.items;
return items.map(item => {
let state = "";
if (item.state === "closed") {
if (item.pull_request) {
state = item.pull_request.merged_at ? "🟣" : "⚫️";
} else {
state = item.state_reason === "completed" ? "🟢" : "⚪️";
}
} else {
if (item.pull_request) {
state = item.draft ? "📝" : "🟠";
} else {
state = "🔴";
}
}
const base = {
...item,
emoji: {
state: state,
type: item.pull_request ? "🔨" : "🐛"
},
created_at: new Date(item.created_at),
closed_at: item.closed_at ? new Date(item.cloased_at) : null
}
if (item.pull_request) {
base.pull_request = {};
base.pull_request.merged_at = item.pull_request.merged_at ? new Date(item.pull_request.merged_at) : null;
}
return base;
});
}

View file

@ -2,6 +2,7 @@ import "./version.ts";
import "./docs.ts";
import "./tag.ts";
import "./ping.ts";
import "./github.ts";
import { registerCommands } from "../loaders/commands.ts";
await registerCommands();

View file

@ -7,3 +7,5 @@ export const COMMIT_HASH = spawnSync({
export const PRODUCTION = process.env.NODE_ENV === "production";
export const MESSAGE_PREFIX = PRODUCTION ? "b" : "<>";

View file

@ -6,7 +6,7 @@ import { MessageCommandContext } from "../structs/context/CommandContext.ts";
import { extname } from "node:path";
import { safeSlice } from "../util.ts";
const GITHUB_LINE_URL_REGEX = /^(?:https?:\/\/)?(?:www\.)?(?:github)\.com\/(?<repo>[a-zA-Z0-9-_]+\/[A-Za-z0-9_.-]+)\/blob\/(?<path>.+?)#L(?<first_line_number>\d+)[-~]?L?(?<second_line_number>\d*)/i;
const GITHUB_LINE_URL_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:github)\.com\/(?<repo>[a-zA-Z0-9-_]+\/[A-Za-z0-9_.-]+)\/blob\/(?<path>.+?)#L(?<first_line_number>\d+)[-~]?L?(?<second_line_number>\d*)/i;
defineListener({
event: Events.MessageCreate,
@ -51,17 +51,17 @@ async function handleGithubLink(message: Message) {
const firstLineNumber = parseInt(groups.first_line_number) - 1;
const secondLineNumber = parseInt(groups.second_line_number) || firstLineNumber + 1;
// limit, max 25 lines - possible flood
if (secondLineNumber - firstLineNumber > 25) {
message.react("❌");
return;
}
const contentUrl = `https://raw.githubusercontent.com/${repo}/${path}`;
const response = await fetch(contentUrl);
const content = await response.text();
const lines = content.split("\n");
// limit, max 25 lines - possible flood
if (secondLineNumber - firstLineNumber > 25 && lines.length > secondLineNumber) {
message.react("❌");
return;
}
let text = "";
for (let i = 0; i < lines.length; i++) {
@ -82,7 +82,7 @@ async function handleGithubLink(message: Message) {
new ButtonBuilder()
.setLabel(repo)
.setStyle(ButtonStyle.Link)
.setURL(`https://github.com/${repo}`)
.setURL(`https://github.com/${repo}/blob/${path}#L${firstLineNumber + 1}${secondLineNumber ? `-L${secondLineNumber}` : ""}`)
)
.toJSON()
]

View file

@ -25,7 +25,7 @@ export interface StringOption {
type: ApplicationCommandOptionType.String;
autocomplete?: boolean;
choices?: APIApplicationCommandOptionChoice[];
run: (interaction: AutocompleteContext) => any;
run?: (interaction: AutocompleteContext) => any;
}
export interface Command {

View file

@ -1,4 +1,5 @@
export function safeSlice<T>(input: T[] | string, length: number) {
export function safeSlice<T>(input: T, length: number) {
// @ts-expect-error i know where im using it
return input.length > length ? input.slice(0, length) : input;
}