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
11 changes: 6 additions & 5 deletions src/adapters/telegram/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand All @@ -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} <owner/repo>`);
await ctx.reply(`Usage: /${command} <owner/repo or GitHub repo URL>`);
return null;
}
return { loc, threadId, repo };
Expand Down
14 changes: 14 additions & 0 deletions src/domain/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 29 additions & 0 deletions test/adapters/telegram/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down
59 changes: 58 additions & 1 deletion test/domain/github.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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> = {}): GithubEvent {
Expand Down
Loading