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
113 linesCodeBlameRaw
1/**
2 * Ask g1t (components/ask-composer): where the person is when they ask,
3 * which goes with each question to their direct message with @g1t, so g1t
4 * is told the page and reads the issue or pull request on it. Pure, so it
5 * is tested on its own.
6 */
7import type { AskEvent, AskPage, AskSource, AskToolStatus } from "@g1t/contracts";
8
9/** First path segments that are g1t's own pages, never a workspace. */
10const RESERVED = new Set([
11 "settings", "u", "auth", "oauth", "integrations", "new", "invite", "workspaces", "device", "verify",
12 "confirm-email", "login", "register", "logout", "forgot", "reset", "search", "status", "avatars", "docs", "-",
13]);
14
15/** Where the person is: the page, and the repository, issue or pull request on it. */
16export function askPage(pathname: string): AskPage {
17 const path = pathname.split(/[?#]/)[0] ?? "";
18 const parts = path.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
19 const page: AskPage = { path: path || "/" };
20 const [owner, name, section, number] = parts;
21 if (!owner || !name || RESERVED.has(owner.toLowerCase()) || name === "-") return page;
22 page.repo = { namespace: owner.toLowerCase(), name };
23 const numbered = number && /^\d+$/.test(number) ? Number(number) : null;
24 if (numbered != null && section === "issues") page.issue = numbered;
25 if (numbered != null && section === "pull") page.pull = numbered;
26 return page;
27}
28
29/** The window event a page sends to have the top bar's Ask open and ask a question (components/ask-button.tsx). */
30export const ASK_EVENT = "g1t:ask";
31
32/**
33 * Opens the top bar's Ask panel and asks g1t this, as if it were typed
34 * there: Home's box, its Summarize and Explain buttons. Where Ask is not
35 * set up, the panel opens with the question in its box.
36 */
37export function askG1t(text: string) {
38 const question = text.trim();
39 if (!question) return;
40 window.dispatchEvent(new CustomEvent<{ text: string }>(ASK_EVENT, { detail: { text: question } }));
41}
42
43/** What the composer says about where the question is asked from. */
44export function askWhere(page: AskPage): string | null {
45 if (page.issue) return `On issue #${page.issue}`;
46 if (page.pull) return `On pull request #${page.pull}`;
47 if (page.repo) return `In ${page.repo.name}`;
48 return null;
49}
50
51/**
52 * A step an answer took: one of g1t's tools, run as the person asking. Its
53 * `tool` line comes when it starts and again, with the same id, when it ends.
54 */
55export type Step = {
56 id: string;
57 name: string;
58 action: string | null;
59 label: string;
60 status: AskToolStatus;
61 summary: string | null;
62 link: string | null;
63};
64
65/** A session g1t proposed in an answer: the person starts it, or copies its command. */
66export type Proposal = Extract<AskEvent, { type: "proposal" }>;
67
68/** What a proposal asks the session to do, in a line: "Implement #12 in acme/web". */
69export function proposalTitle(proposal: Proposal): string {
70 const verb = { plan: "Plan", implement: "Implement", review: "Review" }[proposal.mode] ?? "Work on";
71 return proposal.issue ? `${verb} #${proposal.issue} in ${proposal.repo}` : `${verb} “${proposal.title}” in ${proposal.repo}`;
72}
73
74/**
75 * An answer's steps and sources from the raw events kept with it (a chat
76 * message keeps the `tool` and `sources` lines it was streamed with):
77 * each step once, as it last was, in the order they started; the last
78 * `sources`; each proposal once. Anything else, or anything malformed, is skipped.
79 */
80export function foldAnswer(events: unknown): { steps: Step[]; sources: AskSource[]; proposals: Proposal[] } {
81 let steps: Step[] = [];
82 let sources: AskSource[] = [];
83 const proposals: Proposal[] = [];
84 for (const event of Array.isArray(events) ? events : []) {
85 if (!event || typeof event !== "object") continue;
86 const line = event as AskEvent;
87 if (line.type === "tool" && typeof line.id === "string" && typeof line.label === "string") steps = withStep(steps, line);
88 if (line.type === "sources" && Array.isArray(line.sources)) {
89 sources = line.sources.filter((source) => source && typeof source.url === "string" && typeof source.title === "string");
90 }
91 if (line.type === "proposal" && typeof line.id === "string" && typeof line.repo === "string" && (line.issue || line.title)) {
92 if (!proposals.some((known) => known.id === line.id)) proposals.push(line);
93 }
94 }
95 return { steps, sources, proposals };
96}
97
98/** The steps with this one in its place: a new one at the end, a known one updated. */
99export function withStep(steps: Step[] | undefined, event: Extract<AskEvent, { type: "tool" }>): Step[] {
100 const step: Step = {
101 id: event.id,
102 name: event.name,
103 action: event.action ?? null,
104 label: event.label,
105 status: event.status,
106 summary: event.summary ?? null,
107 link: event.link ?? null,
108 };
109 const list = steps ?? [];
110 const at = list.findIndex((known) => known.id === step.id);
111 if (at === -1) return [...list, step];
112 return list.map((known, index) => (index === at ? step : known));
113}