Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

140 linesCodeBlameRaw
1/**
2 * The integrations directory: the connector catalog (@g1t/contracts/connectors)
3 * filtered, searched and sorted into what a workspace, or a person, has
4 * connected, can connect now, and will be able to soon.
5 */
6
7import { CONNECTOR_CATEGORIES, type ConnectorCategory, type ConnectorScope, type ConnectorView } from "@g1t/contracts/connectors";
8
9/** "all", or one category: what the directory's navigation selects. */
10export type CategoryFilter = ConnectorCategory | "all";
11
12/** What is connected, by connector id: how it is doing, for its card. */
13export type ConnectedState = {
14 /** A line about it: an account, how many connections. */
15 detail: string | null;
16 /** Something is wrong with it, said plainly. */
17 problem: string | null;
18 /** Where it is managed, when that is not its setup page. */
19 manage: string | null;
20};
21
22export type Directory = {
23 connected: ConnectorView[];
24 available: ConnectorView[];
25 soon: ConnectorView[];
26};
27
28/** The category a `?category=` asks for, or all of them. */
29export function categoryFilter(value: string | null | undefined): CategoryFilter {
30 return CONNECTOR_CATEGORIES.some((category) => category.id === value) ? (value as ConnectorCategory) : "all";
31}
32
33/** A category's title. */
34export function categoryTitle(id: ConnectorCategory): string {
35 return CONNECTOR_CATEGORIES.find((category) => category.id === id)?.title ?? id;
36}
37
38/** Lowercased, without accents or punctuation, for matching. */
39function fold(text: string): string {
40 return text
41 .normalize("NFKD")
42 .replace(/[̀-ͯ]/g, "")
43 .toLowerCase()
44 .replace(/[^a-z0-9]+/g, " ")
45 .trim();
46}
47
48/** Whether a connector answers a search: every word in its name, description, category, tags or keywords. */
49export function matchesQuery(view: ConnectorView, query: string): boolean {
50 const words = fold(query).split(" ").filter(Boolean);
51 if (words.length === 0) return true;
52 const haystack = fold([view.name, view.description, categoryTitle(view.category), ...view.capabilities, ...view.keywords].join(" "));
53 const compact = haystack.replace(/ /g, "");
54 return words.every((word) => haystack.includes(word) || compact.includes(word));
55}
56
57/**
58 * The directory for one category and search: what is connected first, then
59 * what can be connected now, then what is coming. Search spans every
60 * category, so a match in another one is never hidden.
61 */
62export function arrange(
63 views: ConnectorView[],
64 { category, query, connected }: { category: CategoryFilter; query: string; connected: ReadonlySet<string> },
65): Directory {
66 const searching = query.trim().length > 0;
67 const shown = views.filter((view) => (searching || category === "all" || view.category === category) && matchesQuery(view, query));
68 return {
69 connected: shown.filter((view) => connected.has(view.id)),
70 available: shown.filter((view) => view.status === "available" && !connected.has(view.id)),
71 soon: shown.filter((view) => view.status === "soon" && !connected.has(view.id)),
72 };
73}
74
75/** How many connectors each category has, for its row in the navigation; `all` is every one. */
76export function categoryCounts(views: ConnectorView[]): Record<CategoryFilter, number> {
77 const counts = { all: views.length } as Record<CategoryFilter, number>;
78 for (const category of CONNECTOR_CATEGORIES) counts[category.id] = 0;
79 for (const view of views) counts[view.category] += 1;
80 return counts;
81}
82
83/** The categories with something in them for `views`, in catalog order. */
84export function categoriesWith(views: ConnectorView[]): { id: ConnectorCategory; title: string }[] {
85 const present = new Set(views.map((view) => view.category));
86 return CONNECTOR_CATEGORIES.filter((category) => present.has(category.id));
87}
88
89/** Connectors grouped under their categories, in catalog order, leaving out empty ones. */
90export function groupByCategory(views: ConnectorView[]): { id: ConnectorCategory; title: string; views: ConnectorView[] }[] {
91 return CONNECTOR_CATEGORIES.map((category) => ({
92 ...category,
93 views: views.filter((view) => view.category === category.id),
94 })).filter((group) => group.views.length > 0);
95}
96
97/**
98 * The letters on a connector's tile: two words' initials, or a name's
99 * capitals ("PagerDuty" → "PD"). Only letters and digits count, so
100 * "GitHub (MCP)" is "GM", never "G(".
101 */
102export function monogram(name: string): string {
103 const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
104 if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase();
105 const word = words[0] ?? name;
106 const capitals = word.slice(1).match(/[A-Z0-9]/);
107 return capitals ? (word[0]! + capitals[0]).toUpperCase() : word.slice(0, 1).toUpperCase();
108}
109
110/** A tile's colour, always the same for the same connector. */
111const PALETTE = ["#8b8ff0", "#5b8def", "#5eead4", "#4ade80", "#facc15", "#fb923c", "#f87171", "#f472b6", "#c084fc", "#a1a1aa", "#38bdf8", "#a3e635"];
112
113export function tileColor(id: string): string {
114 let hash = 0;
115 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
116 return PALETTE[hash % PALETTE.length]!;
117}
118
119/** Where a request for a connector goes: an email to g1t, filled in. */
120export function askHref({
121 connector,
122 scope,
123 workspace,
124 address,
125}: {
126 connector: Pick<ConnectorView, "id" | "name">;
127 scope: ConnectorScope;
128 workspace: string | null;
129 address: string;
130}): string {
131 const subject = `Integration request: ${connector.name}`;
132 const body = [
133 `I would like g1t to connect to ${connector.name} (${connector.id}).`,
134 scope === "workspace" && workspace ? `For the workspace: ${workspace}` : "For my own account.",
135 "",
136 "What we would use it for:",
137 "",
138 ].join("\n");
139 return `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
140}