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.

g1t.sh
137 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 issue or
79 * pull request in front of them, the repository, or their work.
80 */
81export function suggestions(page: AskPage): string[] {
82 if (page.pull) return [`Summarize pull request #${page.pull}`, `What's left before #${page.pull} can merge?`, "What changed in this pull request, file by file?", "What should I check when reviewing this?"];
83 if (page.issue) return [`Summarize issue #${page.issue}`, `What's blocking #${page.issue}?`, "How would you approach this issue?", "Which code does this issue touch?"];
84 if (page.repo) 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?"];
85 return ["What shipped today?", "What needs my attention?", "Summarize what my team is working on", "How do I set up a deployment?"];
86}
87
88/**
89 * Where a question to @g1t was asked from, when it was asked on a page
90 * (the top bar's Ask on a pull request, an issue or a repository): what to
91 * call it and where it leads. Null for a question written in Chat.
92 */
93export function askedFrom(page: AskPage | null | undefined): { label: string; href: string } | null {
94 if (!page) return null;
95 const repo = page.repo ? `${page.repo.namespace}/${page.repo.name}` : null;
96 if (repo && page.pull) return { label: `pull request #${page.pull} in ${repo}`, href: page.path ?? `/${repo}/pull/${page.pull}` };
97 if (repo && page.issue) return { label: `issue #${page.issue} in ${repo}`, href: page.path ?? `/${repo}/issues/${page.issue}` };
98 if (repo) return { label: repo, href: page.path ?? `/${repo}` };
99 if (page.path && !/\/-\/chat(\/|$)/.test(page.path)) return { label: page.path, href: page.path };
100 return null;
101}
102
103/** A link g1t knows how to show as a chip: an issue or pull request, a commit, or another page of the site. */
104export type RefTarget =
105 | { kind: "issue"; repo: string; number: number; href: string }
106 | { kind: "commit"; repo: string; sha: string; href: string }
107 | { kind: "page"; href: string };
108
109/** The site's own hosts: a full link to one of them is shown as the page it leads to. */
110const SITE_HOSTS = new Set(["g1t.sh", "www.g1t.sh"]);
111
112/**
113 * Where a link leads on the site, if it does: `/acme/web/issues/12`,
114 * `/acme/web/pull/12`, `/acme/web/commit/<sha>`, or the same as a full
115 * `https://g1t.sh/…` link (or one to `origin`, the site the reader is on).
116 */
117export function refTarget(href: string, origin: string | null = null): RefTarget | null {
118 let path: string;
119 if (href.startsWith("/") && !href.startsWith("//")) path = href;
120 else {
121 let url: URL;
122 try {
123 url = new URL(href);
124 } catch {
125 return null;
126 }
127 if (url.protocol !== "https:" && url.protocol !== "http:") return null;
128 if (!SITE_HOSTS.has(url.host) && url.origin !== origin) return null;
129 path = `${url.pathname}${url.search}${url.hash}`;
130 }
131 const bare = path.split(/[?#]/)[0]!;
132 const issue = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/([a-z0-9._-]+)\/(?:issues|pull)\/(\d+)\/?$/i);
133 if (issue) return { kind: "issue", repo: `${issue[1]!.toLowerCase()}/${issue[2]}`, number: Number(issue[3]), href: path };
134 const commit = bare.match(/^\/([a-z0-9][a-z0-9-]*)\/([a-z0-9._-]+)\/commit\/([0-9a-f]{7,40})\/?$/i);
135 if (commit) return { kind: "commit", repo: `${commit[1]!.toLowerCase()}/${commit[2]}`, sha: commit[3]!.toLowerCase(), href: path };
136 return { kind: "page", href: path };
137}