From aabc95f7cf3c4b537a735cec1e8b99ca44d6076e Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Sat, 26 Sep 2026 10:12:30 -0400 Subject: [PATCH] feat(telegram): accept GitHub repo URLs in /linkrepo and /unlinkrepo --- src/adapters/telegram/commands.ts | 11 ++--- src/domain/github.ts | 14 ++++++ test/adapters/telegram/commands.test.ts | 29 ++++++++++++ test/domain/github.test.ts | 59 ++++++++++++++++++++++++- 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/adapters/telegram/commands.ts b/src/adapters/telegram/commands.ts index 9045c84..07bc1e9 100644 --- a/src/adapters/telegram/commands.ts +++ b/src/adapters/telegram/commands.ts @@ -10,7 +10,7 @@ import { linkRepoToTopic } from "../../domain/usecases/link-repo-to-topic"; import { unlinkRepo } from "../../domain/usecases/unlink-repo"; import { listRepoLinks } from "../../domain/usecases/list-repo-links"; import { NotFoundError, UnauthorizedError } from "../../domain/errors"; -import { parseRepoFullName } from "../../domain/github"; +import { parseRepoReference } from "../../domain/github"; import type { RepoFullName } from "../../domain/github"; import type { Membership, ProfileField, ProfileFieldName } from "../../domain/entities"; import type { MembershipId, TeamId } from "../../domain/ids"; @@ -198,8 +198,9 @@ function reposReply(links: Array<{ repoFullName: string; threadId: number }>): s // READ-001: the genuinely shared part of `/linkrepo` and `/unlinkrepo` — // the topic gate (spec: "Admin-Only Link/Unlink Inside a Topic") and the -// `owner/repo` argument parsing. Everything else (which use case runs, -// which errors it can throw, the reply text) differs per command and is +// repo argument parsing (`owner/repo` or a pasted GitHub URL). Everything +// else (which use case runs, which errors it can throw, the reply text) +// differs per command and is // registered explicitly below, the same way `/setup`/`/join`/`/datachannel` // each get their own `bot.command` block instead of a shared branching loop. async function resolveLinkCommandTarget( @@ -223,9 +224,9 @@ async function resolveLinkCommandTarget( return null; } const threadId = loc.threadId; - const repo = parseRepoFullName(rawRepoArg.trim()); + const repo = parseRepoReference(rawRepoArg); if (!repo) { - await ctx.reply(`Usage: /${command} `); + await ctx.reply(`Usage: /${command} `); return null; } return { loc, threadId, repo }; diff --git a/src/domain/github.ts b/src/domain/github.ts index 7b12f67..9718c50 100644 --- a/src/domain/github.ts +++ b/src/domain/github.ts @@ -15,6 +15,20 @@ export function parseRepoFullName(raw: string): RepoFullName | null { return REPO_FULL_NAME_PATTERN.test(lower) ? (lower as RepoFullName) : null; } +// A github.com repo URL, with or without scheme or `www.`. Only the owner and +// repo segments are kept: a `.git` suffix, deeper paths (`/pull/17`), query +// and fragment are dropped. +const GITHUB_REPO_URL_PATTERN = + /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/?#\s]+)\/([^/?#\s]+?)(?:\.git)?(?:[/?#].*)?$/i; + +// User input from chat commands: either `owner/repo` or a pasted GitHub URL. +// Webhook payloads keep using the strict parseRepoFullName. +export function parseRepoReference(raw: string): RepoFullName | null { + const trimmed = raw.trim(); + const url = GITHUB_REPO_URL_PATTERN.exec(trimmed); + return parseRepoFullName(url ? `${url[1]}/${url[2]}` : trimmed); +} + export type GithubEventKind = "pull_request" | "issues"; export type GithubEventAction = | "opened" diff --git a/test/adapters/telegram/commands.test.ts b/test/adapters/telegram/commands.test.ts index ac31303..29627bf 100644 --- a/test/adapters/telegram/commands.test.ts +++ b/test/adapters/telegram/commands.test.ts @@ -656,6 +656,21 @@ describe("registerCommands — /linkrepo (repo-topic-links spec)", () => { expect(replies[1]?.text).toMatch(/linked/i); }); + it("accepts a pasted GitHub repo URL", async () => { + const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); + await bot.handleUpdate(commandUpdate("setup", 10, 1)); + deps.githubOrgClaimRepo.rows.push({ teamId: deps.teamRepo.rows[0]!.id, orgLogin: "owner" }); + + await bot.handleUpdate( + commandUpdate("linkrepo", 10, 1, { threadId: 77, args: "https://github.com/Owner/Repo" }), + ); + + expect(deps.repoTopicLinkRepo.rows).toMatchObject([ + { repoFullName: "owner/repo", threadId: 77 }, + ]); + expect(replies[1]?.text).toMatch(/linked/i); + }); + it("refuses an unclaimed org and stores no row", async () => { const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); await bot.handleUpdate(commandUpdate("setup", 10, 1)); @@ -728,6 +743,20 @@ describe("registerCommands — /unlinkrepo (repo-topic-links spec)", () => { expect(replies[2]?.text).toMatch(/unlinked/i); }); + it("accepts a pasted GitHub repo URL", async () => { + const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); + await bot.handleUpdate(commandUpdate("setup", 10, 1)); + deps.githubOrgClaimRepo.rows.push({ teamId: deps.teamRepo.rows[0]!.id, orgLogin: "owner" }); + await bot.handleUpdate(commandUpdate("linkrepo", 10, 1, { threadId: 77, args: "owner/repo" })); + + await bot.handleUpdate( + commandUpdate("unlinkrepo", 10, 1, { threadId: 77, args: "https://github.com/owner/repo/" }), + ); + + expect(deps.repoTopicLinkRepo.rows).toHaveLength(0); + expect(replies[2]?.text).toMatch(/unlinked/i); + }); + it("refuses a non-admin member", async () => { const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); await bot.handleUpdate(commandUpdate("setup", 10, 1)); diff --git a/test/domain/github.test.ts b/test/domain/github.test.ts index 011f6a9..e77bda7 100644 --- a/test/domain/github.test.ts +++ b/test/domain/github.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { formatGithubAlert, parseRepoFullName } from "../../src/domain/github"; +import { + formatGithubAlert, + parseRepoFullName, + parseRepoReference, +} from "../../src/domain/github"; import type { GithubEvent } from "../../src/domain/github"; describe("parseRepoFullName", () => { @@ -27,6 +31,59 @@ describe("parseRepoFullName", () => { it("rejects whitespace inside the name", () => { expect(parseRepoFullName("octo cat/hello world")).toBeNull(); }); + + it("rejects a GitHub URL (webhook payloads are strict)", () => { + expect(parseRepoFullName("https://github.com/octocat/hello-world")).toBeNull(); + }); +}); + +describe("parseRepoReference", () => { + it("accepts a plain owner/repo", () => { + expect(parseRepoReference("OctoCat/Hello-World")).toBe("octocat/hello-world"); + }); + + it("accepts a GitHub repo URL", () => { + expect(parseRepoReference("https://github.com/OctoCat/Hello-World")).toBe( + "octocat/hello-world", + ); + }); + + it("accepts http, www and scheme-less URLs", () => { + expect(parseRepoReference("http://www.github.com/octocat/hello-world")).toBe( + "octocat/hello-world", + ); + expect(parseRepoReference("github.com/octocat/hello-world")).toBe("octocat/hello-world"); + }); + + it("ignores a trailing slash, a .git suffix, deeper paths, query and fragment", () => { + expect(parseRepoReference("https://github.com/octocat/hello-world/")).toBe( + "octocat/hello-world", + ); + expect(parseRepoReference("https://github.com/octocat/hello-world.git")).toBe( + "octocat/hello-world", + ); + expect(parseRepoReference("https://github.com/octocat/hello-world/pull/17")).toBe( + "octocat/hello-world", + ); + expect(parseRepoReference("https://github.com/octocat/hello-world?tab=readme#top")).toBe( + "octocat/hello-world", + ); + }); + + it("rejects URLs from other hosts", () => { + expect(parseRepoReference("https://gitlab.com/octocat/hello-world")).toBeNull(); + expect(parseRepoReference("https://evilgithub.com/octocat/hello-world")).toBeNull(); + }); + + it("rejects a GitHub URL without a repo segment", () => { + expect(parseRepoReference("https://github.com/octocat")).toBeNull(); + expect(parseRepoReference("https://github.com/")).toBeNull(); + }); + + it("rejects malformed plain input", () => { + expect(parseRepoReference("octocat")).toBeNull(); + expect(parseRepoReference("octocat/hello/world")).toBeNull(); + }); }); function makeEvent(overrides: Partial = {}): GithubEvent {