Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ grammyjs
libsql
listcontributors
listrepos
issuelist
lyja
multistream
nodenext
Expand Down
13 changes: 13 additions & 0 deletions locales/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,16 @@ e_comment_created =
<blockquote>{ $commentPreview }</blockquote>

{ $repoHashtag } #{ $type }


cmd_issuelist_empty = 🥲 No open issues found

cmd_issuelist_repo = 📦 <b>{ $repoName }</b>

cmd_issuelist_issue = — <a href="{ $issueUrl }">{ $issueTitle }</a> → { $assignee }

cmd_issuelist_unassigned = Unassigned

cmd_issuelist_header = ✨ <b>Open issues:</b>

cmd_issuelist_total = 💎 <b>Total:</b> <code>{ $count }</code>
2 changes: 2 additions & 0 deletions src/bot/commands/public/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -12,5 +13,6 @@ export const userCommands = new CommandGroup<BotContext<any>>().add([
cmdWhoami,
cmdListContributors,
cmdListRepos,
cmdIssuelist,
cmdHelp,
]);
94 changes: 94 additions & 0 deletions src/bot/commands/public/issuelist.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<string>>();
const resolveAssigneeCached = (login: string, htmlUrl: string): Promise<string> => {
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<Section | null> => {
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 },
],
});
19 changes: 19 additions & 0 deletions src/lib/github/map-with-concurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let cursor = 0;

const worker = async (): Promise<void> => {
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;
}
22 changes: 22 additions & 0 deletions src/lib/telegram/resolve-assignee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { db } from "#db";

import { escapeHtml } from "../escape-html.ts";

export async function resolveAssignee(login: string, htmlUrl: string): Promise<string> {
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 = `(<a href="tg://user?id=${contributor.tgId}">${escapeHtml(contributor.tgName)}</a>)`;
} else if (contributor?.tgId) {
tgStatus = `(<a href="tg://user?id=${contributor.tgId}">-</a>)`;
} else {
tgStatus = "(-)";
}

return `<a href="${escapeHtml(htmlUrl)}">${escapeHtml(login)}</a> ${tgStatus}`;
}