g1t/apps/web/app/routes/repo/why.ts
| 1 | import { data } from "react-router"; |
| 2 | |
| 3 | import type { SessionEntry } from "@g1t/contracts"; |
| 4 | |
| 5 | import type { Route } from "./+types/why"; |
| 6 | import { pullForCommit } from "../../lib/provenance.server"; |
| 7 | import { repos, work } from "../../lib/services.server"; |
| 8 | import { getViewer } from "../../lib/session.server"; |
| 9 | |
| 10 | /** How many steps of the agent's session are shown for one commit. */ |
| 11 | const MAX_STEPS = 8; |
| 12 | /** Longest a step's text is shown. */ |
| 13 | const MAX_STEP_CHARS = 600; |
| 14 | |
| 15 | /** |
| 16 | * The steps of a session behind `hash`: what the agent said and did |
| 17 | * between the previous commit and this one. Entries carry the head commit |
| 18 | * when they were recorded, so the work that produced `hash` is the run of |
| 19 | * entries just before the first one recorded at it. Steps that touch |
| 20 | * `file` come first. |
| 21 | */ |
| 22 | function stepsFor(session: SessionEntry[], hash: string, file: string): SessionEntry[] { |
| 23 | // The sandbox notes each push: "Pushed b1c42490059b." The work behind a |
| 24 | // commit runs from the push before it to the push that carried it. |
| 25 | const pushed = (entry: SessionEntry) => |
| 26 | entry.kind === "note" ? /^Pushed ([0-9a-f]{7,40})/.exec(entry.text)?.[1] ?? null : null; |
| 27 | const end = session.findIndex((entry) => { |
| 28 | const pushedHash = pushed(entry); |
| 29 | return entry.commit === hash || (pushedHash != null && hash.startsWith(pushedHash)); |
| 30 | }); |
| 31 | let window = session; |
| 32 | if (end >= 0) { |
| 33 | let start = end - 1; |
| 34 | while (start >= 0 && pushed(session[start]!) == null) start--; |
| 35 | window = session.slice(start + 1, end + 1); |
| 36 | } |
| 37 | const name = file.split("/").pop() ?? file; |
| 38 | const useful = window.filter( |
| 39 | (entry) => |
| 40 | entry.kind === "message" || |
| 41 | (entry.kind === "tool_call" && (entry.text.includes(file) || entry.text.includes(name))), |
| 42 | ); |
| 43 | const touching = useful.filter((entry) => entry.text.includes(name)); |
| 44 | const chosen = (touching.length > 0 ? [...touching, ...useful.filter((e) => e.kind === "message")] : useful) |
| 45 | .filter((entry, index, all) => all.findIndex((other) => other.seq === entry.seq) === index) |
| 46 | .sort((a, b) => a.seq - b.seq) |
| 47 | .slice(-MAX_STEPS); |
| 48 | return chosen.map((entry) => ({ |
| 49 | ...entry, |
| 50 | text: entry.text.length > MAX_STEP_CHARS ? `${entry.text.slice(0, MAX_STEP_CHARS)}…` : entry.text, |
| 51 | })); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Why a line is the way it is: the commit that last changed it, the pull |
| 56 | * request and issue it came from, and what the agent was thinking. |
| 57 | * Fetched by the blame view when a line is picked. |
| 58 | */ |
| 59 | export async function loader({ params, request, context }: Route.LoaderArgs) { |
| 60 | const viewer = getViewer(context); |
| 61 | const path = { namespace: params.owner, name: params.repo }; |
| 62 | const file = new URL(request.url).searchParams.get("path") ?? ""; |
| 63 | if (!/^[0-9a-f]{40}$/i.test(params.hash)) throw data(null, { status: 404 }); |
| 64 | const [log, pull] = await Promise.all([ |
| 65 | repos.log(path, viewer, params.hash, 1), |
| 66 | pullForCommit(path, viewer, params.hash), |
| 67 | ]); |
| 68 | const commit = log.ok ? log.value[0] : undefined; |
| 69 | if (!commit) throw data(null, { status: 404 }); |
| 70 | if (!pull) return { commit, pull: null, issue: null, steps: [] as SessionEntry[] }; |
| 71 | const [detail, session] = await Promise.all([ |
| 72 | work.getPull(path, pull.number, viewer), |
| 73 | pull.runtime === "hosted" || pull.agent ? work.readSession(path, pull.number, viewer) : null, |
| 74 | ]); |
| 75 | const issue = detail.ok ? detail.value.issue : null; |
| 76 | return { |
| 77 | commit, |
| 78 | pull: { |
| 79 | number: pull.number, |
| 80 | title: pull.title, |
| 81 | agent: pull.agent, |
| 82 | runtime: pull.runtime, |
| 83 | author: pull.author.username, |
| 84 | mergedBy: pull.mergedBy, |
| 85 | mergedAt: pull.mergedAt, |
| 86 | }, |
| 87 | issue: issue && { |
| 88 | number: issue.number, |
| 89 | title: issue.title, |
| 90 | body: issue.body.length > 900 ? `${issue.body.slice(0, 900)}…` : issue.body, |
| 91 | author: issue.author.username, |
| 92 | }, |
| 93 | steps: session?.ok ? stepsFor(session.value, commit.hash, file) : [], |
| 94 | }; |
| 95 | } |