This repository has no description
1// The loop: findings + recording -> PRs/issues on Tangled, with frame citations.
2// A PR's patch is computed from the model's corrected file via `git diff`, so it is
3// guaranteed to apply. If we can't produce a clean patch, the finding is filed as
4// an issue instead of opening a PR with a broken diff.
5
6import { execFile } from "node:child_process";
7import { mkdtemp, readFile, writeFile } from "node:fs/promises";
8import { tmpdir } from "node:os";
9import { join, resolve } from "node:path";
10import { promisify } from "node:util";
11import type { Config } from "./config.js";
12import { extractFrameAt, toSeconds } from "./frames.js";
13import {
14 type BlobRef,
15 buildCommentRecord,
16 buildIssueRecord,
17 buildMarkdown,
18 buildPullRecord,
19 imageMarkdown,
20 NSID,
21 type StrongRef,
22} from "./lexicon.js";
23import { TangledClient } from "./pds.js";
24import type { Finding, WebhookEvent } from "./types.js";
25
26const exec = promisify(execFile);
27
28export interface PostedFinding {
29 kind: string;
30 title: string;
31 uri: string;
32 webUrl?: string;
33 commentUri?: string;
34 file: string | null;
35 line: number | null;
36 dryRun: boolean;
37}
38
39/**
40 * Turn the model's corrected file into a real unified diff: write it over the working
41 * tree, `git diff`, then restore the original. The result is a genuine diff against the
42 * actual source, so it always applies. Returns null if there's no change or git fails.
43 */
44async function patchFromNewContent(cfg: Config, file: string, newContent: string, log: (m: string) => void): Promise<string | null> {
45 const abs = resolve(cfg.repoRoot, file);
46 let original: string;
47 try {
48 original = await readFile(abs, "utf8");
49 } catch {
50 log(`patch: cannot read ${file} — skipping fix`);
51 return null;
52 }
53 if (newContent === original) {
54 log(`patch: model returned ${file} unchanged — no fix`);
55 return null;
56 }
57 try {
58 await writeFile(abs, newContent);
59 const { stdout: diff } = await exec("git", ["-C", cfg.repoRoot, "diff", "--", file]);
60 if (!diff.trim()) return null;
61 return diff;
62 } catch (e: any) {
63 log(`patch: git diff failed for ${file} (${String(e?.message ?? e).split("\n")[0]})`);
64 return null;
65 } finally {
66 await writeFile(abs, original); // always restore the working tree
67 }
68}
69
70/** Verify a patch applies cleanly to the current repo. */
71async function patchApplies(cfg: Config, diff: string): Promise<boolean> {
72 try {
73 const dir = await mkdtemp(join(tmpdir(), "loup-patch-"));
74 const pf = join(dir, "fix.diff");
75 await writeFile(pf, diff.endsWith("\n") ? diff : diff + "\n");
76 await exec("git", ["-C", cfg.repoRoot, "apply", "--check", pf]);
77 return true;
78 } catch {
79 return false;
80 }
81}
82
83async function resolveWebUrls(cfg: Config, posted: PostedFinding[]): Promise<void> {
84 const base = `https://tangled.org/${cfg.targetRepoName}`;
85 for (const [kind, path] of [
86 ["pr", "pulls"],
87 ["issue", "issues"],
88 ] as const) {
89 const mine = posted.filter((p) => p.kind === kind);
90 if (!mine.length) continue;
91 try {
92 const html = await (await fetch(`${base}/${path}`)).text();
93 const re = new RegExp(`/${cfg.targetRepoName.replace(/[.]/g, "\\.")}/${path}/(\\d+)`, "g");
94 const nums = [...new Set([...html.matchAll(re)].map((m) => Number(m[1])))].sort((a, b) => b - a);
95 mine.forEach((it, i) => {
96 const num = nums[mine.length - 1 - i];
97 if (num != null) it.webUrl = `${base}/${path}/${num}`;
98 });
99 } catch {
100 /* best-effort */
101 }
102 }
103}
104
105export async function processEvent(
106 ev: WebhookEvent,
107 findings: Finding[],
108 cfg: Config,
109 framesDir: string,
110 log: (m: string) => void = () => {},
111): Promise<PostedFinding[]> {
112 const client = new TangledClient(cfg);
113 await client.login();
114 const did = cfg.targetRepoDid ?? "did:plc:DRYRUN_TARGET";
115 const now = new Date().toISOString();
116 const out: PostedFinding[] = [];
117
118 for (const f of findings) {
119 // 1. cut the citation frame from the recording at the finding's timestamp
120 const framePath = await extractFrameAt(ev.videoPath, f.at, framesDir);
121
122 // 2. build the patch (PR only). If we can't make a clean one, fall back to an issue.
123 let patchText: string | null = null;
124 let kind = f.kind;
125 if (kind === "pr" && f.file && f.newContent) {
126 const diff = await patchFromNewContent(cfg, f.file, f.newContent, log);
127 if (diff && (await patchApplies(cfg, diff))) {
128 patchText = diff;
129 } else {
130 log(`patch: no clean patch for "${f.title}" — filing as issue`);
131 kind = "issue";
132 }
133 } else if (kind === "pr") {
134 kind = "issue"; // PR with no fix → issue
135 }
136
137 // 3. upload the screenshot; build its citation markdown (deep-linked if hosted)
138 const img = await client.uploadImage(framePath);
139 const sec = toSeconds(f.at);
140 const deepLink = ev.videoUrl ? `${ev.videoUrl}${ev.videoUrl.includes("?") ? "&" : "?"}t=${sec}` : null;
141 const citation = deepLink
142 ? `[${imageMarkdown(img.url, f.title)}](${deepLink})\n\n▶ [Watch at ${f.at}](${deepLink})`
143 : imageMarkdown(img.url, f.title);
144
145 // 4. open the PR or issue
146 const body = renderBody(f, citation);
147 let subject;
148 if (kind === "pr" && patchText) {
149 const patchBlob = (await client.uploadPatch(patchText)).ref;
150 subject = await client.createRecord(
151 NSID.pull,
152 buildPullRecord({
153 targetRepoDid: did,
154 targetBranch: cfg.targetBranch,
155 title: f.title,
156 body,
157 patchBlob,
158 createdAt: now,
159 sourceBranch: `loup/${slug(f.title)}`,
160 }),
161 );
162 } else {
163 subject = await client.createRecord(
164 NSID.issue,
165 buildIssueRecord({ targetRepoDid: did, title: f.title, body, createdAt: now }),
166 );
167 }
168 log(`${kind.toUpperCase()}: ${subject.uri}${subject.dryRun ? " (dry-run)" : ""}`);
169
170 // 5. post the screenshot as a citation comment too
171 const ref: StrongRef = { uri: subject.uri, cid: subject.cid };
172 const comment = await client.createRecord(
173 NSID.comment,
174 buildCommentRecord({
175 subject: ref,
176 body: buildMarkdown(`📎 **Evidence @ ${f.at}**\n\n${citation}`, [img.ref as BlobRef]),
177 createdAt: now,
178 pullRoundIdx: kind === "pr" ? 0 : undefined,
179 }),
180 );
181
182 out.push({
183 kind,
184 title: f.title,
185 uri: subject.uri,
186 commentUri: comment.uri,
187 file: f.file ?? null,
188 line: null,
189 dryRun: subject.dryRun,
190 });
191 }
192
193 if (client.live && out.length) {
194 log("resolving web URLs…");
195 await new Promise((r) => setTimeout(r, 2000));
196 await resolveWebUrls(cfg, out);
197 }
198 return out;
199}
200
201function renderBody(f: Finding, citation: string): string {
202 const lines = [`> 🔎 Opened by **loup** from a user session recording.`, "", f.body];
203 if (f.file) lines.push("", `**Where:** \`${f.file}\``);
204 lines.push("", `### Evidence`, citation);
205 return lines.join("\n");
206}
207
208function slug(s: string): string {
209 return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
210}