thunderbird-patch-review

Easy-to-use patch review interface for Thunderbird

Contribute: ~marcc/thunderbird-review-plugin@lists.sr.ht

git clone git://mccd.space/thunderbird-patch-review

series.js (5863B)

      1 // Collect a full patch series from one displayed message: find the sibling
      2 // patches in the same folder, order them, and fetch their plain-text bodies.
      3 
      4 import { parseSubject, sameSeries, bodyLooksLikeDiff, isReplySubject } from "./patch-detect.js";
      5 
      6 /** Extract the text/plain body from a messages.getFull() part tree. */
      7 export function plainBodyFromPart(part) {
      8   if (!part) {
      9     return "";
     10   }
     11   const contentType = (part.contentType || "").toLowerCase();
     12   if (contentType.startsWith("text/plain") && typeof part.body === "string") {
     13     return part.body;
     14   }
     15   for (const sub of part.parts || []) {
     16     const body = plainBodyFromPart(sub);
     17     if (body) {
     18       return body;
     19     }
     20   }
     21   // Fall back to any textual body at all (some senders use text/x-patch).
     22   if (typeof part.body === "string" && contentType.startsWith("text/")) {
     23     return part.body;
     24   }
     25   return "";
     26 }
     27 
     28 export async function getPlainBody(messageId) {
     29   const full = await messenger.messages.getFull(messageId);
     30   return plainBodyFromPart(full);
     31 }
     32 
     33 /** Query all messages in the anchor's folder within a date window, following pagination. */
     34 async function listFolderMessages(anchor, windowDays) {
     35   const from = new Date(anchor.date.getTime() - windowDays * 86400e3);
     36   const to = new Date(anchor.date.getTime() + windowDays * 86400e3);
     37   const base = { fromDate: from, toDate: to, subject: "PATCH" };
     38 
     39   let page;
     40   try {
     41     // TB 121+ addressing; falls back to the MailFolder object on TB 115.
     42     page = await messenger.messages.query({ ...base, folderId: anchor.folder.id });
     43   } catch (e) {
     44     page = await messenger.messages.query({ ...base, folder: anchor.folder });
     45   }
     46 
     47   const out = [...page.messages];
     48   while (page.id) {
     49     page = await messenger.messages.continueList(page.id);
     50     out.push(...page.messages);
     51   }
     52   return out;
     53 }
     54 
     55 /**
     56  * Collapse a list of same-series entries to one per series index.
     57  *
     58  * Each entry is { header, info, body, hasDiff } (only header.date,
     59  * header.headerMessageId, info.n and hasDiff are read here).
     60  *
     61  * For a real patch slot (n > 0) a body that carries a diff always beats one
     62  * that does not — a reply that shares the subject (some mailing lists strip
     63  * the "Re:" prefix) would otherwise win by date and surface as
     64  * "No diff found in this message". With no diff tiebreak, the more recent
     65  * message wins (handles resends of the same index).
     66  */
     67 export function dedupByIndex(entries) {
     68   const byIndex = new Map();
     69   for (const entry of entries) {
     70     const n = entry.info ? entry.info.n : null;
     71     const key = n === null ? `id:${entry.header.headerMessageId}` : n;
     72     const prev = byIndex.get(key);
     73     if (!prev) {
     74       byIndex.set(key, entry);
     75       continue;
     76     }
     77     if (n !== null && n > 0 && entry.hasDiff !== prev.hasDiff) {
     78       byIndex.set(key, entry.hasDiff ? entry : prev);
     79     } else {
     80       byIndex.set(key, entry.header.date > prev.header.date ? entry : prev);
     81     }
     82   }
     83   return byIndex;
     84 }
     85 
     86 /**
     87  * Build the ordered series for the given message header.
     88  * @returns {Promise<Array<{
     89  *   id: number, headerMessageId: string, subject: string, author: string,
     90  *   date: number, n: number|null, m: number|null, isCover: boolean,
     91  *   title: string, body: string, hasDiff: boolean
     92  * }>>} ordered patches (cover letter first when present)
     93  */
     94 export async function collectSeries(anchor) {
     95   const anchorInfo = parseSubject(anchor.subject);
     96   const members = new Map(); // headerMessageId -> {header, info}
     97 
     98   // Replies (Re:/Aw:/Fwd:) to a patch are not part of the series: skip the
     99   // anchor itself and every folder candidate that is a reply. The subject
    100   // tag still matches through the prefix, so we filter on the prefix here.
    101   if (!isReplySubject(anchor.subject)) {
    102     members.set(anchor.headerMessageId, { header: anchor, info: anchorInfo });
    103   }
    104 
    105   if (anchorInfo && anchorInfo.m !== null) {
    106     let candidates = [];
    107     try {
    108       candidates = await listFolderMessages(anchor, 30);
    109     } catch (e) {
    110       console.warn("patch-review: folder query failed, reviewing single message", e);
    111     }
    112     for (const header of candidates) {
    113       if (isReplySubject(header.subject)) {
    114         continue;
    115       }
    116       const info = parseSubject(header.subject);
    117       if (!sameSeries(anchorInfo, info)) {
    118         continue;
    119       }
    120       const existing = members.get(header.headerMessageId);
    121       if (!existing) {
    122         members.set(header.headerMessageId, { header, info });
    123       }
    124     }
    125   }
    126 
    127   // Fetch bodies up front so the per-index dedup can prefer the message
    128   // that actually carries a diff. A reply that shares the subject (some
    129   // mailing lists strip the "Re:" prefix, and isReplySubject only catches
    130   // the ones that keep it) would otherwise win the slot by date and show up
    131   // as "No diff found in this message".
    132   const entries = [];
    133   for (const { header, info } of members.values()) {
    134     const body = await getPlainBody(header.id);
    135     entries.push({ header, info, body, hasDiff: bodyLooksLikeDiff(body) });
    136   }
    137 
    138   // Keep one message per series index (a resend may duplicate an index).
    139   const byIndex = dedupByIndex(entries);
    140 
    141   const ordered = [...byIndex.values()].sort((a, b) => {
    142     const an = a.info && a.info.n !== null ? a.info.n : 0;
    143     const bn = b.info && b.info.n !== null ? b.info.n : 0;
    144     return an - bn || a.header.date - b.header.date;
    145   });
    146 
    147   const series = [];
    148   for (const { header, info, body, hasDiff } of ordered) {
    149     series.push({
    150       id: header.id,
    151       headerMessageId: header.headerMessageId,
    152       subject: header.subject,
    153       author: header.author,
    154       date: header.date.getTime ? header.date.getTime() : header.date,
    155       n: info ? info.n : null,
    156       m: info ? info.m : null,
    157       isCover: Boolean(info && info.n === 0),
    158       title: info ? info.title : header.subject,
    159       body,
    160       hasDiff,
    161     });
    162   }
    163   return series;
    164 }