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 },
+ ],
+});