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}`;
+}