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
139 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, AskProposalState, 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/** Opens the top bar's Ask panel on the conversation as it is: a follow-up to an answer shown on a page. */
44export function openAsk() {
45 window.dispatchEvent(new CustomEvent<{ text: string }>(ASK_EVENT, { detail: { text: "" } }));
46}
47
48/** What the composer says about where the question is asked from. */
49export function askWhere(page: AskPage): string | null {
50 if (page.issue) return `On issue #${page.issue}`;
51 if (page.pull) return `On pull request #${page.pull}`;
52 if (page.repo) return `In ${page.repo.name}`;
53 return null;
54}
55
56/**
57 * A step an answer took: one of g1t's tools, run as the person asking. Its
58 * `tool` line comes when it starts and again, with the same id, when it ends.
59 */
60export type Step = {
61 id: string;
62 name: string;
63 action: string | null;
64 label: string;
65 status: AskToolStatus;
66 summary: string | null;
67 link: string | null;
68};
69
70/** A session g1t proposed in an answer: the person starts it, or copies its command. */
71export type Proposal = Extract<AskEvent, { type: "proposal" }>;
72
73/** What starting a proposal does, in a line, under its title. */
74export function proposalSteps(proposal: Proposal): string {
75 if (proposal.new_repository) {
76 const made = `Creates ${proposal.private ? "the private" : "the public"} repository`;
77 return proposal.deploy
78 ? `${made}, turns on its deployments, then runs in your own harness and pushes the first version, which goes live on g1t.page.`
79 : `${made}, then runs in your own harness and pushes the first version.`;
80 }
81 return proposal.issue ? "Runs in your own harness, with your skills, tied to the issue." : "Opens the issue, then runs in your own harness, tied to it.";
82}
83
84/** What a proposal asks the session to do, in a line: "Implement #12 in acme/web". */
85export function proposalTitle(proposal: Proposal): string {
86 if (proposal.new_repository) return `Build “${proposal.title}” in ${proposal.repo}`;
87 const verb = { plan: "Plan", implement: "Implement", review: "Review" }[proposal.mode] ?? "Work on";
88 return proposal.issue ? `${verb} #${proposal.issue} in ${proposal.repo}` : `${verb} “${proposal.title}” in ${proposal.repo}`;
89}
90
91/**
92 * An answer's steps and sources from the raw events kept with it (a chat
93 * message keeps the `tool` and `sources` lines it was streamed with):
94 * each step once, as it last was, in the order they started; the last
95 * `sources`; each proposal once, and what its card did (`kept`, by its id).
96 * Anything else, or anything malformed, is skipped.
97 */
98export function foldAnswer(events: unknown): { steps: Step[]; sources: AskSource[]; proposals: Proposal[]; kept: Record<string, AskProposalState> } {
99 let steps: Step[] = [];
100 let sources: AskSource[] = [];
101 const proposals: Proposal[] = [];
102 const kept: Record<string, AskProposalState> = {};
103 for (const event of Array.isArray(events) ? events : []) {
104 if (!event || typeof event !== "object") continue;
105 // What a proposal's card did, kept on the answer by chat's g1t_proposal.
106 const state = event as AskProposalState & { type?: string };
107 if (state.type === "proposal_state" && typeof state.id === "string") {
108 const { type: _, ...rest } = state;
109 kept[state.id] = rest;
110 continue;
111 }
112 const line = event as AskEvent;
113 if (line.type === "tool" && typeof line.id === "string" && typeof line.label === "string") steps = withStep(steps, line);
114 if (line.type === "sources" && Array.isArray(line.sources)) {
115 sources = line.sources.filter((source) => source && typeof source.url === "string" && typeof source.title === "string");
116 }
117 if (line.type === "proposal" && typeof line.id === "string" && typeof line.repo === "string" && (line.issue || line.title)) {
118 if (!proposals.some((known) => known.id === line.id)) proposals.push(line);
119 }
120 }
121 return { steps, sources, proposals, kept };
122}
123
124/** The steps with this one in its place: a new one at the end, a known one updated. */
125export function withStep(steps: Step[] | undefined, event: Extract<AskEvent, { type: "tool" }>): Step[] {
126 const step: Step = {
127 id: event.id,
128 name: event.name,
129 action: event.action ?? null,
130 label: event.label,
131 status: event.status,
132 summary: event.summary ?? null,
133 link: event.link ?? null,
134 };
135 const list = steps ?? [];
136 const at = list.findIndex((known) => known.id === step.id);
137 if (at === -1) return [...list, step];
138 return list.map((known, index) => (index === at ? step : known));
139}