diff --git a/cspell-words.txt b/cspell-words.txt index b0079ce..cdd580e 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -9,7 +9,8 @@ grammyjs libsql listcontributors listrepos -issuelist +listissues +cmdlistissues lyja multistream nodenext diff --git a/locales/en.ftl b/locales/en.ftl index ced4bd5..dee38be 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -250,14 +250,14 @@ e_comment_created = { $repoHashtag } #{ $type } -cmd_issuelist_empty = 🥲 No open issues found +cmd_listissues_empty = 🥲 No open issues found -cmd_issuelist_repo = 📦 { $repoName } +cmd_listissues_repo = 📦 { $repoName } -cmd_issuelist_issue = — { $issueTitle } → { $assignee } +cmd_listissues_issue = — { $issueTitle } → { $assignee } -cmd_issuelist_unassigned = Unassigned +cmd_listissues_unassigned = Unassigned -cmd_issuelist_header = ✨ Open issues: +cmd_listissues_header = ✨ Open issues: -cmd_issuelist_total = 💎 Total: { $count } +cmd_listissues_total = 💎 Total: { $count } diff --git a/src/bot/commands/public/group.ts b/src/bot/commands/public/group.ts index b017203..1c726d5 100644 --- a/src/bot/commands/public/group.ts +++ b/src/bot/commands/public/group.ts @@ -5,6 +5,7 @@ import { CommandGroup } from "@grammyjs/commands"; import { cmdHelp } from "./help.ts"; import { cmdIssuelist } from "./issuelist.ts"; import { cmdListContributors } from "./listcontributors.ts"; +import { cmdlistissues } from "./listissues.ts"; import { cmdListRepos } from "./listrepos.ts"; import { cmdWhoami } from "./whoami.ts"; @@ -13,6 +14,6 @@ export const userCommands = new CommandGroup>().add([ cmdWhoami, cmdListContributors, cmdListRepos, - cmdIssuelist, + cmdlistissues, cmdHelp, ]); diff --git a/src/bot/commands/public/listissues.ts b/src/bot/commands/public/listissues.ts new file mode 100644 index 0000000..eb67293 --- /dev/null +++ b/src/bot/commands/public/listissues.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 listissuesHandler(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_listissues_unassigned"); + + return ctx.t("cmd_listissues_issue", { + emoji: "", + issueUrl: escapeHtml(issue.html_url), + issueTitle: escapeHtml(issue.title), + assignee: assigneeText, + }); + }); + + return { + text: `${ctx.t("cmd_listissues_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_listissues_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_listissues_header")}\n\n${body}\n\n${ctx.t("cmd_listissues_total", { count: totalIssues })}`, + { disable_notification: true }, + ); +} + +export const cmdlistissues = createCommand({ + template: "listissues", + description: "List all open issues grouped by repository", + handler: listissuesHandler, + scopes: [ + { type: "chat", chat_id: config.bot.chatId }, + { type: "chat_administrators", chat_id: config.bot.chatId }, + ], +});