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