This repository has no description
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 7.1 kB View raw
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}