pgroast 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +173 -2
  3. package/bin/pgroast.mjs +19 -0
  4. package/data/precedents.sql.gz +0 -0
  5. package/docs/corpus.md +352 -0
  6. package/docs/design.md +540 -0
  7. package/docs/eval.md +245 -0
  8. package/docs/haas-paranoia-notes.md +223 -0
  9. package/invariants/README.md +15 -0
  10. package/invariants/nbtree.md +14 -0
  11. package/package.json +51 -4
  12. package/prompts/brief.md +32 -0
  13. package/prompts/committer-paranoia.md +149 -0
  14. package/prompts/critic.md +56 -0
  15. package/prompts/enrich.md +35 -0
  16. package/prompts/pushback-map.md +58 -0
  17. package/prompts/pushback-reduce.md +72 -0
  18. package/prompts/reflect.md +20 -0
  19. package/prompts/relevance.md +20 -0
  20. package/prompts/roaster-already-exists.md +23 -0
  21. package/prompts/roaster-common.md +88 -0
  22. package/prompts/roaster-demand.md +28 -0
  23. package/prompts/roaster-maintenance-fit.md +22 -0
  24. package/prompts/roaster-prior-attempts.md +29 -0
  25. package/prompts/roaster-replication-ondisk.md +24 -0
  26. package/prompts/roaster-security.md +21 -0
  27. package/prompts/roaster-standards-compat.md +21 -0
  28. package/prompts/supervisor.md +31 -0
  29. package/prompts/synth-self.md +19 -0
  30. package/prompts/synth.md +140 -0
  31. package/prompts/tone.md +21 -0
  32. package/prompts/writing.md +98 -0
  33. package/sql/schema.sql +45 -0
  34. package/src/cli.ts +391 -0
  35. package/src/corpus/cf.ts +220 -0
  36. package/src/corpus/cli.ts +189 -0
  37. package/src/corpus/db.ts +119 -0
  38. package/src/corpus/fetch.ts +104 -0
  39. package/src/corpus/reverts.ts +133 -0
  40. package/src/corpus/search.ts +201 -0
  41. package/src/corpus/util.ts +133 -0
  42. package/src/corpus/wiki.ts +80 -0
  43. package/src/eval/cases.ts +118 -0
  44. package/src/eval/cli.ts +105 -0
  45. package/src/eval/judge.ts +136 -0
  46. package/src/eval/report.ts +268 -0
  47. package/src/eval/run.ts +153 -0
  48. package/src/eval/score.ts +275 -0
  49. package/src/roast/doctor.ts +109 -0
  50. package/src/roast/enrich.ts +335 -0
  51. package/src/roast/invariants.ts +65 -0
  52. package/src/roast/llm.ts +601 -0
  53. package/src/roast/material.ts +133 -0
  54. package/src/roast/page.ts +266 -0
  55. package/src/roast/pipeline.ts +981 -0
  56. package/src/roast/precedents.ts +308 -0
  57. package/src/roast/pushback.ts +664 -0
  58. package/src/roast/render.ts +386 -0
  59. package/src/roast/sanitize.ts +140 -0
  60. package/src/roast/schema.ts +714 -0
  61. package/src/roast/tone.ts +216 -0
  62. package/src/thread/fetch.ts +172 -0
  63. package/src/thread/load.ts +227 -0
  64. package/src/thread/local.ts +73 -0
  65. package/src/thread/parse.ts +243 -0
  66. package/src/thread/patchprose.ts +143 -0
  67. package/src/thread/types.ts +120 -0
@@ -0,0 +1,73 @@
1
+ // Self mode without a thread: the author's draft email and optional patch files on disk.
2
+ // The same rule as for attachments applies: only the prose of a patch reaches the model.
3
+
4
+ import { readFileSync, statSync } from "node:fs";
5
+ import { basename } from "node:path";
6
+ import { extractPatchProse } from "./patchprose.ts";
7
+ import type { PatchProse, ProposalInput } from "./types.ts";
8
+
9
+ export const MAX_LOCAL_PATCH_BYTES = 8 * 1024 * 1024;
10
+
11
+ export function loadLocalProposal(proposalFile: string, patchFiles: string[] = [], now: Date = new Date()): ProposalInput {
12
+ const text = readFileSync(proposalFile, "utf8").replace(/\r\n/g, "\n");
13
+ if (!text.trim()) throw new Error(`${proposalFile} is empty`);
14
+ const warnings: string[] = [];
15
+
16
+ // A draft may start with mail headers; "Subject:" gives the title, else the first line does.
17
+ const lines = text.split("\n");
18
+ const subj = lines.slice(0, 15).find((l) => /^Subject:/i.test(l));
19
+ const subject = (subj ? subj.replace(/^Subject:\s*/i, "") : (lines.find((l) => l.trim()) ?? "")).trim().slice(0, 200);
20
+ const name = basename(proposalFile);
21
+ const date = now.toISOString().replace(/\.\d+Z$/, "Z");
22
+
23
+ const patchProse: PatchProse[] = [];
24
+ for (const f of patchFiles) {
25
+ const size = statSync(f).size;
26
+ const att = { name: basename(f), url: "", contentType: "text/x-patch", sizeText: `${(size / 1024).toFixed(1)} KB`, sizeBytes: size };
27
+ if (size > MAX_LOCAL_PATCH_BYTES) {
28
+ warnings.push(`patch ${att.name} (${att.sizeText}) is too large; not read`);
29
+ continue;
30
+ }
31
+ const raw = readFileSync(f, "utf8");
32
+ if (raw.includes("\u0000")) {
33
+ warnings.push(`patch ${att.name} looks binary; skipped`);
34
+ continue;
35
+ }
36
+ const prose = extractPatchProse(raw);
37
+ if (prose.files.length === 0 && prose.commitMessages.length === 0) {
38
+ // Not a diff: it could be source code, and code never goes to the model.
39
+ warnings.push(`${att.name} is not a patch (no diff found); not read`);
40
+ continue;
41
+ }
42
+ patchProse.push({ attachment: att, fromMessageIndex: 1, ...prose });
43
+ }
44
+
45
+ return {
46
+ input: name,
47
+ ref: { kind: "local", name },
48
+ cf: null,
49
+ thread: {
50
+ threadUrl: "",
51
+ rootMessageId: "",
52
+ subject,
53
+ lists: ["(draft, not sent)"],
54
+ messages: [
55
+ {
56
+ index: 1,
57
+ messageId: "",
58
+ url: "",
59
+ from: "The author (draft, not sent yet)",
60
+ date,
61
+ subject,
62
+ body: text.trim(),
63
+ attachments: patchProse.map((p) => p.attachment),
64
+ },
65
+ ],
66
+ },
67
+ totalMessages: 1,
68
+ filters: { blind: null, before: null },
69
+ threadStart: date,
70
+ patchProse,
71
+ warnings,
72
+ };
73
+ }
@@ -0,0 +1,243 @@
1
+ // HTML parsers for postgresql.org flat thread pages and CommitFest patch pages.
2
+ // Regex-based on purpose: no dependencies, and the two page layouts are stable and simple.
3
+
4
+ import type { Attachment, CfEntry, CfHistoryItem, CfThreadRef, Thread, ThreadMessage } from "./types.ts";
5
+
6
+ const ARCHIVE = "https://www.postgresql.org";
7
+
8
+ const NAMED_ENTITIES: Record<string, string> = {
9
+ amp: "&",
10
+ lt: "<",
11
+ gt: ">",
12
+ quot: '"',
13
+ apos: "'",
14
+ nbsp: " ",
15
+ ndash: "–",
16
+ mdash: "—",
17
+ hellip: "…",
18
+ copy: "©",
19
+ };
20
+
21
+ export function decodeEntities(s: string): string {
22
+ return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (m, e: string) => {
23
+ if (e[0] === "#") {
24
+ const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
25
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m;
26
+ }
27
+ return NAMED_ENTITIES[e] ?? m;
28
+ });
29
+ }
30
+
31
+ /** Strip tags and collapse whitespace: for single-line fields. */
32
+ export function stripTags(s: string): string {
33
+ return decodeEntities(s.replace(/<[^>]*>/g, " "))
34
+ .replace(/\s+/g, " ")
35
+ .trim();
36
+ }
37
+
38
+ /** Convert a message body to plain text, keeping line structure. */
39
+ export function htmlToText(html: string): string {
40
+ const s = html
41
+ .replace(/\r/g, "")
42
+ .replace(/\n/g, " ")
43
+ .replace(/<br\s*\/?>/gi, "\n")
44
+ .replace(/<\/p>\s*<p[^>]*>/gi, "\n\n")
45
+ .replace(/<\/?(p|div|pre|blockquote)[^>]*>/gi, "\n")
46
+ .replace(/<[^>]*>/g, "");
47
+ return decodeEntities(s)
48
+ .split("\n")
49
+ .map((l) => l.replace(/[ \t]+$/g, "").replace(/^ (?=\S)/, ""))
50
+ .join("\n")
51
+ .replace(/\n{3,}/g, "\n\n")
52
+ .trim();
53
+ }
54
+
55
+ /** "2024-09-12 11:08:15" -> "2024-09-12T11:08:15Z"; null when not a timestamp. */
56
+ export function normTimestamp(s: string): string | null {
57
+ const m = s.match(/(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)/);
58
+ if (!m) return null;
59
+ const time = m[2]!.length === 5 ? m[2] + ":00" : m[2];
60
+ return `${m[1]}T${time}Z`;
61
+ }
62
+
63
+ export function parseSize(s: string): number | null {
64
+ const m = s.trim().match(/^([\d.]+)\s*(bytes|B|KB|kB|MB|GB)$/i);
65
+ if (!m) return null;
66
+ const unit = m[2]!.toLowerCase();
67
+ const mult = unit === "kb" ? 1024 : unit === "mb" ? 1024 ** 2 : unit === "gb" ? 1024 ** 3 : 1;
68
+ return Math.round(Number(m[1]) * mult);
69
+ }
70
+
71
+ export function messageUrl(messageId: string): string {
72
+ return `${ARCHIVE}/message-id/${encodeURIComponent(messageId)}`;
73
+ }
74
+
75
+ export function flatThreadUrl(messageId: string): string {
76
+ return `${ARCHIVE}/message-id/flat/${encodeURIComponent(messageId)}`;
77
+ }
78
+
79
+ function headerField(block: string, name: string): string {
80
+ const re = new RegExp(`<th[^>]*>\\s*${name}:\\s*</th>\\s*<td[^>]*>([\\s\\S]*?)</td>`, "i");
81
+ const m = block.match(re);
82
+ return m ? stripTags(m[1]!) : "";
83
+ }
84
+
85
+ function parseAttachments(block: string): Attachment[] {
86
+ const out: Attachment[] = [];
87
+ const table = block.match(/<table[^>]*message-attachments[^>]*>([\s\S]*?)<\/table>/i);
88
+ if (!table) return out;
89
+ const rowRe =
90
+ /<tr>\s*<th[^>]*>\s*<a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>\s*<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>/gi;
91
+ for (const m of table[1]!.matchAll(rowRe)) {
92
+ const href = decodeEntities(m[1]!);
93
+ const sizeText = stripTags(m[4]!);
94
+ out.push({
95
+ name: stripTags(m[2]!),
96
+ url: href.startsWith("http") ? href : ARCHIVE + href,
97
+ contentType: stripTags(m[3]!),
98
+ sizeText,
99
+ sizeBytes: parseSize(sizeText),
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Parse https://www.postgresql.org/message-id/flat/<msgid>.
107
+ * Throws when the page holds no message (e.g. an error page).
108
+ */
109
+ export function parseFlatThread(html: string): Thread {
110
+ const subjM = html.match(/<h1[^>]*id="flatMsgSubject"[^>]*>([\s\S]*?)<\/h1>/i);
111
+ const lists = [
112
+ ...new Set([...html.matchAll(/<span class="listname"><a[^>]*>([^<]+)<\/a>/g)].map((m) => m[1]!.trim())),
113
+ ];
114
+
115
+ // Each message starts with a header table that holds a "From:" row. The page-level
116
+ // header table (only "Lists:") is skipped by that test.
117
+ const parts = html.split(/<table[^>]*class="[^"]*message-header[^"]*"[^>]*>/i).slice(1);
118
+ const messages: ThreadMessage[] = [];
119
+ for (const part of parts) {
120
+ const headerEnd = part.indexOf("</table>");
121
+ if (headerEnd < 0) continue;
122
+ const header = part.slice(0, headerEnd);
123
+ if (!/<th[^>]*>\s*From:/i.test(header)) continue;
124
+ const rest = part.slice(headerEnd);
125
+
126
+ const idM = header.match(/<th[^>]*>\s*Message-ID:\s*<\/th>\s*<td[^>]*>\s*<a[^>]*>([\s\S]*?)<\/a>/i);
127
+ const messageId = idM ? stripTags(idM[1]!) : "";
128
+ if (!messageId) continue;
129
+ const bodyM = rest.match(/<div class="message-content">([\s\S]*?)<\/div>/i);
130
+ const date = normTimestamp(headerField(header, "Date"));
131
+ messages.push({
132
+ index: messages.length + 1,
133
+ messageId,
134
+ url: messageUrl(messageId),
135
+ from: headerField(header, "From"),
136
+ date: date ?? "",
137
+ subject: headerField(header, "Subject"),
138
+ body: bodyM ? htmlToText(bodyM[1]!) : "",
139
+ attachments: parseAttachments(rest),
140
+ });
141
+ }
142
+ if (messages.length === 0) throw new Error("no messages found: not a flat thread page");
143
+ const root = messages[0]!;
144
+ return {
145
+ threadUrl: flatThreadUrl(root.messageId),
146
+ rootMessageId: root.messageId,
147
+ subject: subjM ? stripTags(subjM[1]!) : root.subject,
148
+ lists,
149
+ messages,
150
+ };
151
+ }
152
+
153
+ function cfFields(html: string): Map<string, string> {
154
+ const out = new Map<string, string>();
155
+ const tableM = html.match(/<table class="table table-bordered">([\s\S]*)/i);
156
+ if (!tableM) return out;
157
+ // Top-level rows only: "History" holds a nested table, so cut it off first.
158
+ const histAt = tableM[1]!.search(/<th>\s*History\s*<\/th>/i);
159
+ const main = histAt >= 0 ? tableM[1]!.slice(0, histAt) : tableM[1]!;
160
+ for (const m of main.matchAll(/<th[^>]*>([\s\S]*?)<\/th>\s*<td[^>]*>([\s\S]*?)(?=<th[^>]*>|$)/gi)) {
161
+ out.set(stripTags(m[1]!), m[2]!);
162
+ }
163
+ // "Emails" is the last row before "History" and can hold a nested table (annotations) with
164
+ // its own <th> cells: take the whole rest of the main table for it.
165
+ const emailsAt = main.search(/<th>\s*Emails\s*<\/th>/i);
166
+ if (emailsAt >= 0) out.set("Emails", main.slice(emailsAt).replace(/^<th>\s*Emails\s*<\/th>\s*<td[^>]*>/i, ""));
167
+ return out;
168
+ }
169
+
170
+ /** Parse https://commitfest.postgresql.org/patch/<id>/. */
171
+ export function parseCommitfestPatch(html: string, id: number): CfEntry {
172
+ const f = cfFields(html);
173
+ const get = (k: string) => f.get(k) ?? "";
174
+ const title = stripTags(get("Title"));
175
+ if (!title) throw new Error(`CommitFest patch ${id}: no title found (not a patch page?)`);
176
+
177
+ const statuses = [
178
+ ...get("Status").matchAll(
179
+ /<a href="\/(\d+)\/">([^<]+)<\/a>[^<]*<span class="badge[^"]*">([^<]+)<\/span>/g,
180
+ ),
181
+ ].map((m) => ({ cfId: Number(m[1]), cfName: m[2]!.trim(), status: m[3]!.trim() }));
182
+ const latest = [...statuses].sort((a, b) => b.cfId - a.cfId)[0];
183
+
184
+ const tags = [...get("Tags").matchAll(/<span class="badge"[^>]*>([^<]+)<\/span>/g)].map((m) =>
185
+ decodeEntities(m[1]!).trim(),
186
+ );
187
+
188
+ const threads: CfThreadRef[] = [];
189
+ for (const m of get("Emails").matchAll(/<dt>([\s\S]*?)<\/dt>\s*<dd>([\s\S]*?)<\/dd>/g)) {
190
+ const a = m[1]!.match(/<a href="https:\/\/www\.postgresql\.org\/message-id\/flat\/([^"]+)">([\s\S]*?)<\/a>/);
191
+ if (!a) continue;
192
+ let messageId = decodeEntities(a[1]!);
193
+ try {
194
+ messageId = decodeURIComponent(messageId);
195
+ } catch {
196
+ // keep as is
197
+ }
198
+ const first = m[2]!.match(/First at <a[^>]*>([^<]+)<\/a> by ([\s\S]*?)<br/);
199
+ const last = m[2]!.match(/Latest at <a[^>]*>([^<]+)<\/a>/);
200
+ threads.push({
201
+ messageId,
202
+ url: flatThreadUrl(messageId),
203
+ subject: stripTags(a[2]!),
204
+ firstAt: first ? normTimestamp(first[1]!) : null,
205
+ firstBy: first ? stripTags(first[2]!).replace(/\s*<.*$/, "") : "",
206
+ latestAt: last ? normTimestamp(last[1]!) : null,
207
+ });
208
+ }
209
+
210
+ const history: CfHistoryItem[] = [];
211
+ const histM = html.match(/<th>\s*History\s*<\/th>([\s\S]*?)<\/table>/i);
212
+ if (histM) {
213
+ for (const r of histM[1]!.matchAll(
214
+ /<tr>\s*<td[^>]*>([^<]+)<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>/g,
215
+ )) {
216
+ history.push({
217
+ when: normTimestamp(r[1]!) ?? r[1]!.trim(),
218
+ who: stripTags(r[2]!),
219
+ what: stripTags(r[3]!),
220
+ });
221
+ }
222
+ }
223
+
224
+ const reviewers = stripTags(get("Reviewers").replace(/<a class="btn[\s\S]*?<\/a>/g, ""));
225
+ return {
226
+ id,
227
+ url: `https://commitfest.postgresql.org/patch/${id}/`,
228
+ title,
229
+ status: latest?.status ?? null,
230
+ statuses,
231
+ authors: stripTags(get("Authors") || get("Author")),
232
+ reviewers,
233
+ committer: stripTags(get("Committer")),
234
+ tags,
235
+ topic: stripTags(get("Topic")),
236
+ targetVersion: stripTags(get("Target version")),
237
+ created: normTimestamp(stripTags(get("Created"))),
238
+ threads,
239
+ history,
240
+ links: [...get("Links").matchAll(/href="(https?:\/\/[^"]+)"/g)].map((m) => decodeEntities(m[1]!)),
241
+ redacted: false,
242
+ };
243
+ }
@@ -0,0 +1,143 @@
1
+ // Extract the prose of a patch file: commit message(s), the list of touched paths, and
2
+ // diff hunks for documentation files only. Code hunks are dropped here, in code, so no
3
+ // later stage can feed them to the model: PG Roast judges the idea, not the lines.
4
+
5
+ import type { CommitMessage } from "./types.ts";
6
+
7
+ export interface ProseExtract {
8
+ commitMessages: CommitMessage[];
9
+ files: string[];
10
+ docHunks: { path: string; text: string }[];
11
+ truncated: boolean;
12
+ }
13
+
14
+ export const MAX_COMMIT_MESSAGE_CHARS = 6000;
15
+ export const MAX_DOC_HUNK_CHARS = 6000;
16
+ export const MAX_DOC_TOTAL_CHARS = 16000;
17
+ export const MAX_FILES = 80;
18
+
19
+ /** Paths whose diff hunks are prose, not code. */
20
+ export function isDocPath(path: string): boolean {
21
+ return (
22
+ /^doc\//.test(path) ||
23
+ /(^|\/)README[^/]*$/i.test(path) ||
24
+ /\.(md|sgml|rst|txt)$/i.test(path) && !/(^|\/)(expected|output|sql|data)\//.test(path)
25
+ );
26
+ }
27
+
28
+ function clip(s: string, max: number): { text: string; cut: boolean } {
29
+ if (s.length <= max) return { text: s, cut: false };
30
+ return { text: s.slice(0, max) + "\n[... truncated ...]", cut: true };
31
+ }
32
+
33
+ /** RFC 2047 "=?UTF-8?q?...?=" subjects, as git format-patch emits for non-ASCII. */
34
+ function decodeMimeWords(s: string): string {
35
+ return s.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=\s*/g, (m, _cs: string, enc: string, data: string) => {
36
+ try {
37
+ if (enc.toLowerCase() === "b") return Buffer.from(data, "base64").toString("utf8");
38
+ const bytes = data
39
+ .replace(/_/g, " ")
40
+ .replace(/=([0-9A-Fa-f]{2})/g, (_x, h: string) => String.fromCharCode(parseInt(h, 16)));
41
+ return Buffer.from(bytes, "latin1").toString("utf8");
42
+ } catch {
43
+ return m;
44
+ }
45
+ });
46
+ }
47
+
48
+ export function extractPatchProse(raw: string): ProseExtract {
49
+ const text = raw.replace(/\r\n/g, "\n");
50
+ let truncated = false;
51
+
52
+ // Split into per-file diffs. Everything before the first "diff --git" of a commit is header.
53
+ const lines = text.split("\n");
54
+ const commitMessages: CommitMessage[] = [];
55
+ const files: string[] = [];
56
+ const docHunks: { path: string; text: string }[] = [];
57
+ let docTotal = 0;
58
+
59
+ let header: string[] = [];
60
+ let cur: { path: string; lines: string[] } | null = null;
61
+
62
+ const flushFile = () => {
63
+ if (!cur) return;
64
+ if (!files.includes(cur.path)) files.push(cur.path);
65
+ if (isDocPath(cur.path) && docTotal < MAX_DOC_TOTAL_CHARS) {
66
+ const c = clip(cur.lines.join("\n"), Math.min(MAX_DOC_HUNK_CHARS, MAX_DOC_TOTAL_CHARS - docTotal));
67
+ truncated ||= c.cut;
68
+ docTotal += c.text.length;
69
+ docHunks.push({ path: cur.path, text: c.text });
70
+ } else if (isDocPath(cur.path)) {
71
+ truncated = true;
72
+ }
73
+ cur = null;
74
+ };
75
+
76
+ const flushHeader = () => {
77
+ const cm = parseCommitHeader(header);
78
+ if (cm) {
79
+ const c = clip(cm.body, MAX_COMMIT_MESSAGE_CHARS);
80
+ truncated ||= c.cut;
81
+ commitMessages.push({ subject: cm.subject, body: c.text });
82
+ }
83
+ header = [];
84
+ };
85
+
86
+ for (const line of lines) {
87
+ const d = line.match(/^diff --git a\/(\S+) b\/(\S+)/);
88
+ if (d) {
89
+ if (cur) flushFile();
90
+ else flushHeader();
91
+ cur = { path: d[2]!, lines: [line] };
92
+ continue;
93
+ }
94
+ // A new mail header inside a multi-commit file ends the previous commit's last file.
95
+ if (/^From [0-9a-f]{40} /.test(line)) {
96
+ if (cur) flushFile();
97
+ else flushHeader();
98
+ header = [line];
99
+ continue;
100
+ }
101
+ if (cur) cur.lines.push(line);
102
+ else header.push(line);
103
+ }
104
+ if (cur) flushFile();
105
+ else flushHeader();
106
+
107
+ if (files.length > MAX_FILES) {
108
+ truncated = true;
109
+ files.length = MAX_FILES;
110
+ }
111
+ return { commitMessages, files, docHunks, truncated };
112
+ }
113
+
114
+ /** Parse a git format-patch mail header block: Subject (with continuation lines) + body. */
115
+ function parseCommitHeader(header: string[]): CommitMessage | null {
116
+ const si = header.findIndex((l) => /^Subject:/i.test(l));
117
+ if (si < 0) return null;
118
+ let subject = header[si]!.replace(/^Subject:\s*/i, "");
119
+ let i = si + 1;
120
+ while (i < header.length && /^[ \t]+\S/.test(header[i]!)) {
121
+ subject += " " + header[i]!.trim();
122
+ i++;
123
+ }
124
+ subject = decodeMimeWords(subject)
125
+ .replace(/^\[PATCH[^\]]*\]\s*/i, "")
126
+ .trim();
127
+ // Skip remaining mail headers up to the blank line.
128
+ while (i < header.length && header[i]!.trim() !== "") i++;
129
+ const bodyLines: string[] = [];
130
+ for (i = i + 1; i < header.length; i++) {
131
+ const l = header[i]!;
132
+ if (l === "---") break; // start of the diffstat
133
+ bodyLines.push(l);
134
+ }
135
+ return { subject, body: bodyLines.join("\n").trim() };
136
+ }
137
+
138
+ /** True when an attachment name looks like a text patch or a prose file worth reading. */
139
+ export function isReadableAttachment(name: string, contentType: string): boolean {
140
+ if (/\.(gz|bz2|xz|zst|zip|tgz|tar|png|jpe?g|gif|pdf|svg|odt|docx?|xlsx?|bin)$/i.test(name)) return false;
141
+ if (/\.(patch|diff|txt|md|nocfbot)$/i.test(name) || /README/i.test(name)) return true;
142
+ return /^text\//i.test(contentType);
143
+ }
@@ -0,0 +1,120 @@
1
+ // Types for the thread / CommitFest input layer.
2
+
3
+ export interface Attachment {
4
+ name: string;
5
+ url: string;
6
+ contentType: string;
7
+ /** Size as shown by the archive, e.g. "33.5 KB". */
8
+ sizeText: string;
9
+ /** Approximate size in bytes parsed from sizeText, or null. */
10
+ sizeBytes: number | null;
11
+ }
12
+
13
+ export interface ThreadMessage {
14
+ /** 1-based position in the flat thread (archive order = by date). */
15
+ index: number;
16
+ messageId: string;
17
+ /** Permalink of this message in the archive. */
18
+ url: string;
19
+ from: string;
20
+ /** ISO 8601, UTC (the archive shows UTC). */
21
+ date: string;
22
+ subject: string;
23
+ body: string;
24
+ attachments: Attachment[];
25
+ }
26
+
27
+ export interface Thread {
28
+ /** https://www.postgresql.org/message-id/flat/<root message-id> */
29
+ threadUrl: string;
30
+ rootMessageId: string;
31
+ subject: string;
32
+ lists: string[];
33
+ messages: ThreadMessage[];
34
+ }
35
+
36
+ export interface CfThreadRef {
37
+ messageId: string;
38
+ url: string;
39
+ subject: string;
40
+ firstAt: string | null;
41
+ firstBy: string;
42
+ latestAt: string | null;
43
+ }
44
+
45
+ export interface CfHistoryItem {
46
+ when: string;
47
+ who: string;
48
+ what: string;
49
+ }
50
+
51
+ export interface CfEntry {
52
+ id: number;
53
+ url: string;
54
+ title: string;
55
+ /** Status in the most recent CommitFest, e.g. "Needs review". Null when redacted. */
56
+ status: string | null;
57
+ statuses: { cfId: number; cfName: string; status: string }[];
58
+ authors: string;
59
+ reviewers: string;
60
+ committer: string;
61
+ tags: string[];
62
+ topic: string;
63
+ targetVersion: string;
64
+ created: string | null;
65
+ threads: CfThreadRef[];
66
+ history: CfHistoryItem[];
67
+ links: string[];
68
+ /** True when outcome-bearing fields were removed for --blind / --before. */
69
+ redacted: boolean;
70
+ }
71
+
72
+ export interface CommitMessage {
73
+ subject: string;
74
+ body: string;
75
+ }
76
+
77
+ /** The prose parts of one patch attachment. Never contains code hunks. */
78
+ export interface PatchProse {
79
+ attachment: Attachment;
80
+ fromMessageIndex: number;
81
+ commitMessages: CommitMessage[];
82
+ /** Paths touched (from the diff headers). Names only. */
83
+ files: string[];
84
+ /** Diff hunks for doc/, README* and *.md / *.sgml / *.rst files only. */
85
+ docHunks: { path: string; text: string }[];
86
+ truncated: boolean;
87
+ }
88
+
89
+ export interface LoadOptions {
90
+ /** Keep only the first N messages. */
91
+ blind?: number;
92
+ /** Keep only messages strictly before this date (YYYY-MM-DD, UTC). */
93
+ before?: string;
94
+ offline?: boolean;
95
+ cacheDir?: string;
96
+ /** Skip attachment download. */
97
+ noAttachments?: boolean;
98
+ }
99
+
100
+ export type InputRef =
101
+ | { kind: "cf"; id: number }
102
+ | { kind: "msgid"; messageId: string }
103
+ /** A draft on disk (self mode): no thread exists yet. */
104
+ | { kind: "local"; name: string };
105
+
106
+ export interface ProposalInput {
107
+ /** The CLI argument as given. */
108
+ input: string;
109
+ ref: InputRef;
110
+ cf: CfEntry | null;
111
+ /** The thread after --blind / --before filtering. */
112
+ thread: Thread;
113
+ /** Number of messages in the thread before filtering. */
114
+ totalMessages: number;
115
+ filters: { blind: number | null; before: string | null };
116
+ /** ISO date-time of the first message of the thread. */
117
+ threadStart: string;
118
+ patchProse: PatchProse[];
119
+ warnings: string[];
120
+ }