diff --git a/cspell-words.txt b/cspell-words.txt index 570335d..b0079ce 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -9,6 +9,7 @@ grammyjs libsql listcontributors listrepos +issuelist lyja multistream nodenext diff --git a/locales/en.ftl b/locales/en.ftl index 1ec0dce..ced4bd5 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -248,3 +248,16 @@ e_comment_created =
{ $commentPreview }
{ $repoHashtag } #{ $type } + + +cmd_issuelist_empty = 🥲 No open issues found + +cmd_issuelist_repo = 📦 { $repoName } + +cmd_issuelist_issue = — { $issueTitle } → { $assignee } + +cmd_issuelist_unassigned = Unassigned + +cmd_issuelist_header = ✨ Open issues: + +cmd_issuelist_total = 💎 Total: { $count } diff --git a/src/bot/commands/public/group.ts b/src/bot/commands/public/group.ts index dfe005c..b017203 100644 --- a/src/bot/commands/public/group.ts +++ b/src/bot/commands/public/group.ts @@ -3,6 +3,7 @@ import type { BotContext } from "#bot"; import { CommandGroup } from "@grammyjs/commands"; import { cmdHelp } from "./help.ts"; +import { cmdIssuelist } from "./issuelist.ts"; import { cmdListContributors } from "./listcontributors.ts"; import { cmdListRepos } from "./listrepos.ts"; import { cmdWhoami } from "./whoami.ts"; @@ -12,5 +13,6 @@ export const userCommands = new CommandGroup>().add([ cmdWhoami, cmdListContributors, cmdListRepos, + cmdIssuelist, cmdHelp, ]); diff --git a/src/bot/commands/public/issuelist.ts b/src/bot/commands/public/issuelist.ts new file mode 100644 index 0000000..534fed2 --- /dev/null +++ b/src/bot/commands/public/issuelist.ts @@ -0,0 +1,94 @@ +import type { BotContext } from "#bot"; + +import { config } from "#config"; +import { octokit } from "#github"; +import { createCommand } from "#telegram"; + +import { escapeHtml } from "../../../lib/escape-html.ts"; +import { mapWithConcurrency } from "../../../lib/github/map-with-concurrency.ts"; +import { resolveAssignee } from "../../../lib/telegram/resolve-assignee.ts"; + +const REPO_CONCURRENCY = 5; +const ISSUE_CONCURRENCY = 10; + +interface Section { + text: string; + count: number; +} + +export async function issuelistHandler(ctx: BotContext) { + const org = config.github.orgName; + + const repos = await octokit.paginate(octokit.rest.repos.listForOrg, { + org, + type: "public", + per_page: 100, + }); + + const activeRepos = repos.filter((r) => !r.archived); + + // We cache the value as a Promise so concurrent requests are deduped too. + const assigneeCache = new Map>(); + const resolveAssigneeCached = (login: string, htmlUrl: string): Promise => { + let cached = assigneeCache.get(login); + if (!cached) { + cached = resolveAssignee(login, htmlUrl); + assigneeCache.set(login, cached); + } + return cached; + }; + + const rawSections = await mapWithConcurrency(activeRepos, REPO_CONCURRENCY, async (repo): Promise
=> { + const issues = await octokit.paginate(octokit.rest.issues.listForRepo, { + owner: org, + repo: repo.name, + state: "open", + per_page: 100, + }); + + const openIssues = issues.filter((i) => !i.pull_request); + if (openIssues.length === 0) return null; + + const issueLines = await mapWithConcurrency(openIssues, ISSUE_CONCURRENCY, async (issue) => { + const assigneeText = issue.assignee + ? await resolveAssigneeCached(issue.assignee.login, issue.assignee.html_url) + : ctx.t("cmd_issuelist_unassigned"); + + return ctx.t("cmd_issuelist_issue", { + emoji: "", + issueUrl: escapeHtml(issue.html_url), + issueTitle: escapeHtml(issue.title), + assignee: assigneeText, + }); + }); + + return { + text: `${ctx.t("cmd_issuelist_repo", { repoName: repo.name })}\n${issueLines.join("\n")}`, + count: openIssues.length, + }; + }); + + const sections = rawSections.filter((s): s is Section => s !== null); + + if (sections.length === 0) { + return await ctx.html.replyToMessage(ctx.t("cmd_issuelist_empty")); + } + + const totalIssues = sections.reduce((acc, s) => acc + s.count, 0); + + const body = sections.map((s) => s.text).join("\n\n"); + return await ctx.html.replyToMessage( + `${ctx.t("cmd_issuelist_header")}\n\n${body}\n\n${ctx.t("cmd_issuelist_total", { count: totalIssues })}`, + { disable_notification: true }, + ); +} + +export const cmdIssuelist = createCommand({ + template: "issuelist", + description: "List all open issues grouped by repository", + handler: issuelistHandler, + scopes: [ + { type: "chat", chat_id: config.bot.chatId }, + { type: "chat_administrators", chat_id: config.bot.chatId }, + ], +}); diff --git a/src/lib/github/map-with-concurrency.ts b/src/lib/github/map-with-concurrency.ts new file mode 100644 index 0000000..aa38588 --- /dev/null +++ b/src/lib/github/map-with-concurrency.ts @@ -0,0 +1,19 @@ +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let cursor = 0; + + const worker = async (): Promise => { + const index = cursor++; + if (index >= items.length) return; + results[index] = await fn(items[index], index); + return worker(); + }; + + const workerCount = Math.min(limit, items.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} diff --git a/src/lib/telegram/resolve-assignee.ts b/src/lib/telegram/resolve-assignee.ts new file mode 100644 index 0000000..4114d39 --- /dev/null +++ b/src/lib/telegram/resolve-assignee.ts @@ -0,0 +1,22 @@ +import { db } from "#db"; + +import { escapeHtml } from "../escape-html.ts"; + +export async function resolveAssignee(login: string, htmlUrl: string): Promise { + const contributor = await db.query.contributors.findFirst({ + where: (f, o) => o.eq(f.ghUsername, login), + }); + + let tgStatus: string; + if (contributor?.tgUsername) { + tgStatus = `(@${escapeHtml(contributor.tgUsername)})`; + } else if (contributor?.tgName && contributor?.tgId) { + tgStatus = `(${escapeHtml(contributor.tgName)})`; + } else if (contributor?.tgId) { + tgStatus = `(-)`; + } else { + tgStatus = "(-)"; + } + + return `${escapeHtml(login)} ${tgStatus}`; +}