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.

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