From 1b334496be5f264ae948b6014d1e13d9b54cf779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=B1=D1=95=D0=BD=D0=BA=CE=B1=CE=B7?= Date: Thu, 13 Aug 2026 01:00:58 +0330 Subject: [PATCH 1/4] feat: add issuelist command --- cspell-words.txt | 1 + locales/en.ftl | 9 +++ src/bot/commands/public/group.ts | 2 + src/bot/commands/public/issuelist.ts | 98 ++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 src/bot/commands/public/issuelist.ts 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..b2580d5 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -248,3 +248,12 @@ e_comment_created =
{ $commentPreview }
{ $repoHashtag } #{ $type } + + +cmd_issuelist_empty = 🥲 No open issues found + +cmd_issuelist_repo = { $repoName } + +cmd_issuelist_issue = { $emoji }{ $issueTitle } => { $assignee } + +cmd_issuelist_unassigned = Unassigned 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..6c29b3a --- /dev/null +++ b/src/bot/commands/public/issuelist.ts @@ -0,0 +1,98 @@ +import { config } from "#config"; +import { db } from "#db"; +import { createCommand } from "#telegram"; + +import type { BotContext } from "../../bot.ts"; + +import { escapeHtml } from "../../../lib/escape-html.ts"; +import { octokit } from "../../../lib/github/github.ts"; + +function getStatusEmoji(labels: (string | { name?: string | null })[]): string { + const names = labels.map((l) => (typeof l === "string" ? l : (l.name ?? ""))); + if (names.includes("Todo")) return "📝 "; + if (names.includes("In Progress")) return "🔄 "; + return ""; +} + +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}`; +} + +export async function issuelistHandler(ctx: BotContext) { + const org = config.github.orgName; + + const { data: repos } = await octokit.rest.repos.listForOrg({ + org, + type: "public", + per_page: 100, + }); + + const activeRepos = repos.filter((r) => !r.archived); + + const sections = ( + await Promise.all( + activeRepos.map(async (repo) => { + const { data: issues } = await 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 Promise.all( + openIssues.map(async (issue) => { + const emoji = getStatusEmoji(issue.labels); + const assigneeText = issue.assignee + ? await resolveAssignee(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 `${ctx.t("cmd_issuelist_repo", { repoName: repo.name })}\n${issueLines.join("\n")}`; + }), + ) + ).filter(Boolean); + + if (sections.length === 0) { + return await ctx.html.replyToMessage(ctx.t("cmd_issuelist_empty")); + } + + return await ctx.html.replyToMessage(sections.join("\n\n"), { + 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 }, + ], +}); From 64c2b6bed2354094e098b0c888731e8826773aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=B1=D1=95=D0=BD=D0=BA=CE=B1=CE=B7?= Date: Thu, 13 Aug 2026 09:47:46 +0330 Subject: [PATCH 2/4] style: issuelist shown format --- locales/en.ftl | 8 ++++++-- src/bot/commands/public/issuelist.ts | 23 +++++++++++------------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/locales/en.ftl b/locales/en.ftl index b2580d5..ced4bd5 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -252,8 +252,12 @@ e_comment_created = cmd_issuelist_empty = 🥲 No open issues found -cmd_issuelist_repo = { $repoName } +cmd_issuelist_repo = 📦 { $repoName } -cmd_issuelist_issue = { $emoji }{ $issueTitle } => { $assignee } +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/issuelist.ts b/src/bot/commands/public/issuelist.ts index 6c29b3a..512a25a 100644 --- a/src/bot/commands/public/issuelist.ts +++ b/src/bot/commands/public/issuelist.ts @@ -7,13 +7,6 @@ import type { BotContext } from "../../bot.ts"; import { escapeHtml } from "../../../lib/escape-html.ts"; import { octokit } from "../../../lib/github/github.ts"; -function getStatusEmoji(labels: (string | { name?: string | null })[]): string { - const names = labels.map((l) => (typeof l === "string" ? l : (l.name ?? ""))); - if (names.includes("Todo")) return "📝 "; - if (names.includes("In Progress")) return "🔄 "; - return ""; -} - async function resolveAssignee(login: string, htmlUrl: string): Promise { const contributor = await db.query.contributors.findFirst({ where: (f, o) => o.eq(f.ghUsername, login), @@ -59,13 +52,12 @@ export async function issuelistHandler(ctx: BotContext) { const issueLines = await Promise.all( openIssues.map(async (issue) => { - const emoji = getStatusEmoji(issue.labels); const assigneeText = issue.assignee ? await resolveAssignee(issue.assignee.login, issue.assignee.html_url) : ctx.t("cmd_issuelist_unassigned"); return ctx.t("cmd_issuelist_issue", { - emoji, + emoji: "", issueUrl: escapeHtml(issue.html_url), issueTitle: escapeHtml(issue.title), assignee: assigneeText, @@ -82,9 +74,16 @@ export async function issuelistHandler(ctx: BotContext) { return await ctx.html.replyToMessage(ctx.t("cmd_issuelist_empty")); } - return await ctx.html.replyToMessage(sections.join("\n\n"), { - disable_notification: true, - }); + const totalIssues = sections.reduce((acc, section) => { + const issueCount = (section ?? "").split("\n").length - 1; + return acc + issueCount; + }, 0); + + const body = sections.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({ From eeae964e12836f9b415e92a3578ea01631e2ec31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=B1=D1=95=D0=BD=D0=BA=CE=B1=CE=B7?= Date: Fri, 14 Aug 2026 10:27:27 +0330 Subject: [PATCH 3/4] fix: add limit to parallel requests --- src/bot/commands/public/issuelist.ts | 113 ++++++++++++------------- src/lib/github/map-with-concurrency.ts | 19 +++++ src/lib/telegram/resolve-assignee.ts | 22 +++++ 3 files changed, 96 insertions(+), 58 deletions(-) create mode 100644 src/lib/github/map-with-concurrency.ts create mode 100644 src/lib/telegram/resolve-assignee.ts diff --git a/src/bot/commands/public/issuelist.ts b/src/bot/commands/public/issuelist.ts index 512a25a..534fed2 100644 --- a/src/bot/commands/public/issuelist.ts +++ b/src/bot/commands/public/issuelist.ts @@ -1,35 +1,25 @@ +import type { BotContext } from "#bot"; + import { config } from "#config"; -import { db } from "#db"; +import { octokit } from "#github"; import { createCommand } from "#telegram"; -import type { BotContext } from "../../bot.ts"; - import { escapeHtml } from "../../../lib/escape-html.ts"; -import { octokit } from "../../../lib/github/github.ts"; +import { mapWithConcurrency } from "../../../lib/github/map-with-concurrency.ts"; +import { resolveAssignee } from "../../../lib/telegram/resolve-assignee.ts"; -async function resolveAssignee(login: string, htmlUrl: string): Promise { - const contributor = await db.query.contributors.findFirst({ - where: (f, o) => o.eq(f.ghUsername, login), - }); +const REPO_CONCURRENCY = 5; +const ISSUE_CONCURRENCY = 10; - 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}`; +interface Section { + text: string; + count: number; } export async function issuelistHandler(ctx: BotContext) { const org = config.github.orgName; - const { data: repos } = await octokit.rest.repos.listForOrg({ + const repos = await octokit.paginate(octokit.rest.repos.listForOrg, { org, type: "public", per_page: 100, @@ -37,49 +27,56 @@ export async function issuelistHandler(ctx: BotContext) { const activeRepos = repos.filter((r) => !r.archived); - const sections = ( - await Promise.all( - activeRepos.map(async (repo) => { - const { data: issues } = await 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 Promise.all( - openIssues.map(async (issue) => { - const assigneeText = issue.assignee - ? await resolveAssignee(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 `${ctx.t("cmd_issuelist_repo", { repoName: repo.name })}\n${issueLines.join("\n")}`; - }), - ) - ).filter(Boolean); + // 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, section) => { - const issueCount = (section ?? "").split("\n").length - 1; - return acc + issueCount; - }, 0); + const totalIssues = sections.reduce((acc, s) => acc + s.count, 0); - const body = sections.join("\n\n"); + 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 }, 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}`; +} From f7149a8101e79afaf940fdea1b196b1a1bce7f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=B1=D1=95=D0=BD=D0=BA=CE=B1=CE=B7?= Date: Sun, 16 Aug 2026 00:48:55 +0330 Subject: [PATCH 4/4] refactor: rename issuelist command to listissues --- cspell-words.txt | 3 ++- locales/en.ftl | 12 ++++++------ src/bot/commands/public/group.ts | 4 ++-- .../public/{issuelist.ts => listissues.ts} | 18 +++++++++--------- 4 files changed, 19 insertions(+), 18 deletions(-) rename src/bot/commands/public/{issuelist.ts => listissues.ts} (82%) 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..ca24389 100644 --- a/src/bot/commands/public/group.ts +++ b/src/bot/commands/public/group.ts @@ -3,8 +3,8 @@ 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 { cmdlistissues } from "./listissues.ts"; import { cmdListRepos } from "./listrepos.ts"; import { cmdWhoami } from "./whoami.ts"; @@ -13,6 +13,6 @@ export const userCommands = new CommandGroup>().add([ cmdWhoami, cmdListContributors, cmdListRepos, - cmdIssuelist, + cmdlistissues, cmdHelp, ]); diff --git a/src/bot/commands/public/issuelist.ts b/src/bot/commands/public/listissues.ts similarity index 82% rename from src/bot/commands/public/issuelist.ts rename to src/bot/commands/public/listissues.ts index 534fed2..eb67293 100644 --- a/src/bot/commands/public/issuelist.ts +++ b/src/bot/commands/public/listissues.ts @@ -16,7 +16,7 @@ interface Section { count: number; } -export async function issuelistHandler(ctx: BotContext) { +export async function listissuesHandler(ctx: BotContext) { const org = config.github.orgName; const repos = await octokit.paginate(octokit.rest.repos.listForOrg, { @@ -52,9 +52,9 @@ export async function issuelistHandler(ctx: BotContext) { 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"); + : ctx.t("cmd_listissues_unassigned"); - return ctx.t("cmd_issuelist_issue", { + return ctx.t("cmd_listissues_issue", { emoji: "", issueUrl: escapeHtml(issue.html_url), issueTitle: escapeHtml(issue.title), @@ -63,7 +63,7 @@ export async function issuelistHandler(ctx: BotContext) { }); return { - text: `${ctx.t("cmd_issuelist_repo", { repoName: repo.name })}\n${issueLines.join("\n")}`, + text: `${ctx.t("cmd_listissues_repo", { repoName: repo.name })}\n${issueLines.join("\n")}`, count: openIssues.length, }; }); @@ -71,22 +71,22 @@ export async function issuelistHandler(ctx: BotContext) { const sections = rawSections.filter((s): s is Section => s !== null); if (sections.length === 0) { - return await ctx.html.replyToMessage(ctx.t("cmd_issuelist_empty")); + 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_issuelist_header")}\n\n${body}\n\n${ctx.t("cmd_issuelist_total", { count: totalIssues })}`, + `${ctx.t("cmd_listissues_header")}\n\n${body}\n\n${ctx.t("cmd_listissues_total", { count: totalIssues })}`, { disable_notification: true }, ); } -export const cmdIssuelist = createCommand({ - template: "issuelist", +export const cmdlistissues = createCommand({ + template: "listissues", description: "List all open issues grouped by repository", - handler: issuelistHandler, + handler: listissuesHandler, scopes: [ { type: "chat", chat_id: config.bot.chatId }, { type: "chat_administrators", chat_id: config.bot.chatId },