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.

168 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 work item 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",
12 "u",
13 "auth",
14 "oauth",
15 "integrations",
16 "new",
17 "invite",
18 "workspaces",
19 "device",
20 "verify",
21 "confirm-email",
22 "login",
23 "register",
24 "logout",
25 "forgot",
26 "reset",
27 "search",
28 "status",
29 "avatars",
30 "docs",
31 "-",
32]);
33
34/** Where the person is: the page, and the repository, work item or pull request on it. */
35export function askPage(pathname: string): AskPage {
36 const path = pathname.split(/[?#]/)[0] ?? "";
37 const parts = path
38 .split("/")
39 .filter(Boolean)
40 .map((part) => decodeURIComponent(part));
41 const page: AskPage = { path: path || "/" };
42 const [owner, name, section, number] = parts;
43 if (!owner || !name || RESERVED.has(owner.toLowerCase())) return page;
44 // A work item's page: `/<workspace>/-/work/<n>`, numbered in the workspace.
45 if (name === "-") {
46 const item = section === "work" && number && /^\d+$/.test(number) ? Number(number) : null;
47 if (item != null) Object.assign(page, { workspace: owner.toLowerCase(), item });
48 return page;
49 }
50 page.repo = { namespace: owner.toLowerCase(), name };
51 const numbered = number && /^\d+$/.test(number) ? Number(number) : null;
52 if (numbered != null && section === "pull") page.pull = numbered;
53 return page;
54}
55
56/** The window event a page sends to have the top bar's Ask open and ask a question (components/ask-button.tsx). */
57export const ASK_EVENT = "g1t:ask";
58
59/**
60 * Opens the top bar's Ask panel and asks g1t this, as if it were typed
61 * there: Home's box, its Summarize and Explain buttons. Where Ask is not
62 * set up, the panel opens with the question in its box.
63 */
64export function askG1t(text: string) {
65 const question = text.trim();
66 if (!question) return;
67 window.dispatchEvent(new CustomEvent<{ text: string }>(ASK_EVENT, { detail: { text: question } }));
68}
69
70/** Opens the top bar's Ask panel on the conversation as it is: a follow-up to an answer shown on a page. */
71export function openAsk() {
72 window.dispatchEvent(new CustomEvent<{ text: string }>(ASK_EVENT, { detail: { text: "" } }));
73}
74
75/** What the composer says about where the question is asked from. */
76export function askWhere(page: AskPage): string | null {
77 if (page.item) return `On work item #${page.item}`;
78 if (page.pull) return `On pull request #${page.pull}`;
79 if (page.repo) return `In ${page.repo.name}`;
80 return null;
81}
82
83/**
84 * A step an answer took: one of g1t's tools, run as the person asking. Its
85 * `tool` line comes when it starts and again, with the same id, when it ends.
86 */
87export type Step = {
88 id: string;
89 name: string;
90 action: string | null;
91 label: string;
92 status: AskToolStatus;
93 summary: string | null;
94 link: string | null;
95};
96
97/** A session g1t proposed in an answer: the person starts it, or copies its command. */
98export type Proposal = Extract<AskEvent, { type: "proposal" }>;
99
100/** What starting a proposal does, in a line, under its title. */
101export function proposalSteps(proposal: Proposal): string {
102 if (proposal.new_repository) {
103 const made = `Creates ${proposal.private ? "the private" : "the public"} repository`;
104 return proposal.deploy
105 ? `${made}, turns on its deployments, then runs in your own harness and pushes the first version, which goes live on g1t.page.`
106 : `${made}, then runs in your own harness and pushes the first version.`;
107 }
108 return proposal.item
109 ? "Runs in your own harness, with your skills, tied to the work item."
110 : "Opens the work item, then runs in your own harness, tied to it.";
111}
112
113/** What a proposal asks the session to do, in a line: "Implement #12 in acme/web". */
114export function proposalTitle(proposal: Proposal): string {
115 if (proposal.new_repository) return `Build “${proposal.title}” in ${proposal.repo}`;
116 const verb = { plan: "Plan", implement: "Implement", review: "Review" }[proposal.mode] ?? "Work on";
117 return proposal.item ? `${verb} #${proposal.item} in ${proposal.repo}` : `${verb} “${proposal.title}” in ${proposal.repo}`;
118}
119
120/**
121 * An answer's steps and sources from the raw events kept with it (a chat
122 * message keeps the `tool` and `sources` lines it was streamed with):
123 * each step once, as it last was, in the order they started; the last
124 * `sources`; each proposal once, and what its card did (`kept`, by its id).
125 * Anything else, or anything malformed, is skipped.
126 */
127export function foldAnswer(events: unknown): { steps: Step[]; sources: AskSource[]; proposals: Proposal[]; kept: Record<string, AskProposalState> } {
128 let steps: Step[] = [];
129 let sources: AskSource[] = [];
130 const proposals: Proposal[] = [];
131 const kept: Record<string, AskProposalState> = {};
132 for (const event of Array.isArray(events) ? events : []) {
133 if (!event || typeof event !== "object") continue;
134 // What a proposal's card did, kept on the answer by chat's g1t_proposal.
135 const state = event as AskProposalState & { type?: string };
136 if (state.type === "proposal_state" && typeof state.id === "string") {
137 const { type: _, ...rest } = state;
138 kept[state.id] = rest;
139 continue;
140 }
141 const line = event as AskEvent;
142 if (line.type === "tool" && typeof line.id === "string" && typeof line.label === "string") steps = withStep(steps, line);
143 if (line.type === "sources" && Array.isArray(line.sources)) {
144 sources = line.sources.filter((source) => source && typeof source.url === "string" && typeof source.title === "string");
145 }
146 if (line.type === "proposal" && typeof line.id === "string" && typeof line.repo === "string" && (line.item || line.title)) {
147 if (!proposals.some((known) => known.id === line.id)) proposals.push(line);
148 }
149 }
150 return { steps, sources, proposals, kept };
151}
152
153/** The steps with this one in its place: a new one at the end, a known one updated. */
154export function withStep(steps: Step[] | undefined, event: Extract<AskEvent, { type: "tool" }>): Step[] {
155 const step: Step = {
156 id: event.id,
157 name: event.name,
158 action: event.action ?? null,
159 label: event.label,
160 status: event.status,
161 summary: event.summary ?? null,
162 link: event.link ?? null,
163 };
164 const list = steps ?? [];
165 const at = list.findIndex((known) => known.id === step.id);
166 if (at === -1) return [...list, step];
167 return list.map((known, index) => (index === at ? step : known));
168}