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
99 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/** What the composer says about where the question is asked from. */
30export function askWhere(page: AskPage): string | null {
31 if (page.issue) return `On issue #${page.issue}`;
32 if (page.pull) return `On pull request #${page.pull}`;
33 if (page.repo) return `In ${page.repo.name}`;
34 return null;
35}
36
37/**
38 * A step an answer took: one of g1t's tools, run as the person asking. Its
39 * `tool` line comes when it starts and again, with the same id, when it ends.
40 */
41export type Step = {
42 id: string;
43 name: string;
44 action: string | null;
45 label: string;
46 status: AskToolStatus;
47 summary: string | null;
48 link: string | null;
49};
50
51/** A session g1t proposed in an answer: the person starts it, or copies its command. */
52export type Proposal = Extract<AskEvent, { type: "proposal" }>;
53
54/** What a proposal asks the session to do, in a line: "Implement #12 in acme/web". */
55export function proposalTitle(proposal: Proposal): string {
56 const verb = { plan: "Plan", implement: "Implement", review: "Review" }[proposal.mode] ?? "Work on";
57 return proposal.issue ? `${verb} #${proposal.issue} in ${proposal.repo}` : `${verb} “${proposal.title}” in ${proposal.repo}`;
58}
59
60/**
61 * An answer's steps and sources from the raw events kept with it (a chat
62 * message keeps the `tool` and `sources` lines it was streamed with):
63 * each step once, as it last was, in the order they started; the last
64 * `sources`; each proposal once. Anything else, or anything malformed, is skipped.
65 */
66export function foldAnswer(events: unknown): { steps: Step[]; sources: AskSource[]; proposals: Proposal[] } {
67 let steps: Step[] = [];
68 let sources: AskSource[] = [];
69 const proposals: Proposal[] = [];
70 for (const event of Array.isArray(events) ? events : []) {
71 if (!event || typeof event !== "object") continue;
72 const line = event as AskEvent;
73 if (line.type === "tool" && typeof line.id === "string" && typeof line.label === "string") steps = withStep(steps, line);
74 if (line.type === "sources" && Array.isArray(line.sources)) {
75 sources = line.sources.filter((source) => source && typeof source.url === "string" && typeof source.title === "string");
76 }
77 if (line.type === "proposal" && typeof line.id === "string" && typeof line.repo === "string" && (line.issue || line.title)) {
78 if (!proposals.some((known) => known.id === line.id)) proposals.push(line);
79 }
80 }
81 return { steps, sources, proposals };
82}
83
84/** The steps with this one in its place: a new one at the end, a known one updated. */
85export function withStep(steps: Step[] | undefined, event: Extract<AskEvent, { type: "tool" }>): Step[] {
86 const step: Step = {
87 id: event.id,
88 name: event.name,
89 action: event.action ?? null,
90 label: event.label,
91 status: event.status,
92 summary: event.summary ?? null,
93 link: event.link ?? null,
94 };
95 const list = steps ?? [];
96 const at = list.findIndex((known) => known.id === step.id);
97 if (at === -1) return [...list, step];
98 return list.map((known, index) => (index === at ? step : known));
99}