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.

162 linesCodeBlameRaw
1/**
2 * @g1t's side of chat, as the site shows it: the direct message every
3 * member has with g1t, which the top bar's Ask (components/ask-composer)
4 * and Chat both show. Pure, so it is tested on its own (g1t-chat.test.ts).
5 */
6import type { AskPage, ChatAnswer, ChatLiveEvent, ChatMessage } from "@g1t/contracts";
7
8/** Whether a message is from @g1t itself. */
9export function fromG1t(message: Pick<ChatMessage, "author">): boolean {
10 return message.author.kind === "system" && message.author.id === "g1t";
11}
12
13/** Whether an answer is still being written. */
14export function answering(answer: ChatAnswer | null | undefined): boolean {
15 return answer?.state === "thinking" || answer?.state === "streaming";
16}
17
18/** Whether any answer in the conversation is still being written: Stop shows, Send waits. */
19export function anyAnswering(messages: readonly ChatMessage[]): boolean {
20 return messages.some((message) => !message.deleted_at && answering(message.answer));
21}
22
23/** Who wrote an answer: "Anthropic · claude-…", or "Anthropic · Auto · claude-…" when Auto chose it, once the provider said. */
24export function answerLabel(answer: ChatAnswer | null | undefined): string | null {
25 if (!answer?.provider_label) return null;
26 if (!answer.model) return answer.provider_label;
27 return answer.auto ? `${answer.provider_label} · Auto · ${answer.model}` : `${answer.provider_label} · ${answer.model}`;
28}
29
30/** Whether an answer was cut off at its length limit. */
31export function cutOff(answer: ChatAnswer | null | undefined): boolean {
32 return answer?.stop_reason === "max_tokens" || answer?.stop_reason === "length";
33}
34
35/**
36 * A conversation's top-level messages after one live event: new and
37 * changed ones by id, oldest first; an answer as it streams; deleted ones
38 * gone. Thread replies and other conversations' events change nothing.
39 */
40export function applyLive(list: readonly ChatMessage[], event: ChatLiveEvent, channelId: string): ChatMessage[] {
41 switch (event.type) {
42 case "message.created":
43 case "message.updated": {
44 const message = event.message;
45 if (message.channel_id !== channelId || message.thread_root) return list as ChatMessage[];
46 if (message.deleted_at) return list.filter((m) => m.id !== message.id);
47 const at = list.findIndex((m) => m.id === message.id);
48 if (at >= 0) return list.map((m) => (m.id === message.id ? { ...m, ...message } : m));
49 return [...list, message].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
50 }
51 case "message.streaming": {
52 if (event.channel_id !== channelId) return list as ChatMessage[];
53 return list.map((m) =>
54 // Never back over what a stored message already has: a late frame after the end changes nothing.
55 m.id === event.id && answering(m.answer) ? { ...m, body: event.body, answer: event.answer } : m,
56 );
57 }
58 case "message.deleted":
59 if (event.channel_id !== channelId) return list as ChatMessage[];
60 return list.filter((m) => m.id !== event.id);
61 case "channel.cleared":
62 // The person cleared the conversation: nothing in it is left.
63 return event.channel_id === channelId ? [] : (list as ChatMessage[]);
64 default:
65 return list as ChatMessage[];
66 }
67}
68
69/**
70 * Whether what was typed to @g1t is `/clear`: it opens Clear conversation
71 * (components/chat/clear-g1t.tsx) rather than being sent to g1t.
72 */
73export function isClearCommand(text: string): boolean {
74 return text.trim().toLowerCase() === "/clear";
75}
76
77/**
78 * What a reader may ask first, from where they are: about the work item or
79 * pull request in front of them, the repository, or their work.
80 */
81/** The work item a page's path is, by number: `/acme/-/work/12`. */
82function itemAt(path: string | null | undefined): number | null {
83 const found = path?.match(/^\/[a-z0-9][a-z0-9-]*\/-\/work\/(\d+)\/?$/i);
84 return found ? Number(found[1]) : null;
85}
86
87export function suggestions(page: AskPage): string[] {
88 if (page.pull)
89 return [
90 `Summarize pull request #${page.pull}`,
91 `What's left before #${page.pull} can merge?`,
92 "What changed in this pull request, file by file?",
93 "What should I check when reviewing this?",
94 ];
95 const item = page.item ?? itemAt(page.path);
96 if (item)
97 return [`Summarize work item #${item}`, `What's blocking #${item}?`, "How would you approach this work item?", "Which code does this work item touch?"];
98 if (page.repo)
99 return [`What is ${page.repo.name} for?`, "What shipped here this week?", "What's open and needs attention?", "Where do I start reading the code?"];
100 return ["What shipped today?", "What needs my attention?", "Summarize what my team is working on", "How do I set up a deployment?"];
101}
102
103/**
104 * Where a question to @g1t was asked from, when it was asked on a page
105 * (the top bar's Ask on a pull request, a work item or a repository): what
106 * to call it and where it leads. Null for a question written in Chat.
107 */
108export function askedFrom(page: AskPage | null | undefined): { label: string; href: string } | null {
109 if (!page) return null;
110 const repo = page.repo ? `${page.repo.namespace}/${page.repo.name}` : null;
111 if (repo && page.pull) return { label: `pull request #${page.pull} in ${repo}`, href: page.path ?? `/${repo}/pull/${page.pull}` };
112 const workspace = page.workspace ?? page.path?.match(/^\/([a-z0-9][a-z0-9-]*)\/-\/work\/\d+\/?$/i)?.[1]?.toLowerCase() ?? null;
113 const item = page.item ?? itemAt(page.path);
114 if (workspace && item) return { label: `work item #${item} in ${workspace}`, href: page.path ?? `/${workspace}/-/work/${item}` };
115 if (repo) return { label: repo, href: page.path ?? `/${repo}` };
116 if (page.path && !/\/-\/chat(\/|$)/.test(page.path)) return { label: page.path, href: page.path };
117 return null;
118}
119
120/** A link g1t knows how to show as a chip: a work item, a pull request, a reference read in a repository, a commit, or another page of the site. */
121export type RefTarget =
122 | { kind: "item"; workspace: string; number: number; href: string }
123 | { kind: "pull"; repo: string; number: number; href: string }
124 | { kind: "ref"; repo: string; number: number; href: string }
125 | { kind: "commit"; repo: string; sha: string; href: string }
126 | { kind: "page"; href: string };
127
128/** The site's own hosts: a full link to one of them is shown as the page it leads to. */
129const SITE_HOSTS = new Set(["g1t.sh", "www.g1t.sh"]);
130
131/**
132 * Where a link leads on the site, if it does: `/acme/-/work/12`,
133 * `/acme/web/pull/12`, `/acme/web/ref/12` (and an old issue's
134 * `/acme/web/issues/12`, read the same way), `/acme/web/commit/<sha>`, or
135 * the same as a full `https://g1t.sh/…` link (or one to `origin`, the site
136 * the reader is on).
137 */
138export function refTarget(href: string, origin: string | null = null): RefTarget | null {
139 let path: string;
140 if (href.startsWith("/") && !href.startsWith("//")) path = href;
141 else {
142 let url: URL;
143 try {
144 url = new URL(href);
145 } catch {
146 return null;
147 }
148 if (url.protocol !== "https:" && url.protocol !== "http:") return null;
149 if (!SITE_HOSTS.has(url.host) && url.origin !== origin) return null;
150 path = `${url.pathname}${url.search}${url.hash}`;
151 }
152 const bare = path.split(/[?#]/)[0]!;
153 const item = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/-\/work\/(\d+)\/?$/i);
154 if (item) return { kind: "item", workspace: item[1]!.toLowerCase(), number: Number(item[2]), href: path };
155 const pull = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/([a-z0-9._-]+)\/pull\/(\d+)\/?$/i);
156 if (pull) return { kind: "pull", repo: `${pull[1]!.toLowerCase()}/${pull[2]}`, number: Number(pull[3]), href: path };
157 const ref = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/([a-z0-9._-]+)\/(?:ref|issues)\/(\d+)\/?$/i);
158 if (ref) return { kind: "ref", repo: `${ref[1]!.toLowerCase()}/${ref[2]}`, number: Number(ref[3]), href: path };
159 const commit = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/([a-z0-9._-]+)\/commit\/([0-9a-f]{7,40})\/?$/i);
160 if (commit) return { kind: "commit", repo: `${commit[1]!.toLowerCase()}/${commit[2]}`, sha: commit[3]!.toLowerCase(), href: path };
161 return { kind: "page", href: path };
162}