From d3c187a62f7675737d23404643760acec37d96dc Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 19 May 2026 03:02:51 -0300 Subject: [PATCH] feat(triage): add incremental resyncIdeaFile (append-only) --- scripts/features/lib/resync.mjs | 84 +++++++++++++++++++++ tests/unit/feature-triage/resync.test.mjs | 91 +++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 scripts/features/lib/resync.mjs create mode 100644 tests/unit/feature-triage/resync.test.mjs diff --git a/scripts/features/lib/resync.mjs b/scripts/features/lib/resync.mjs new file mode 100644 index 0000000000..0a21de7e22 --- /dev/null +++ b/scripts/features/lib/resync.mjs @@ -0,0 +1,84 @@ +/** + * Incremental re-sync of idea files: append new comments only, + * update frontmatter snapshot. + */ +import { parseFrontmatter, serializeFrontmatter, stripFrontmatter } from "./frontmatter.mjs"; +import { isBot } from "./classify.mjs"; + +function countThumbs(reactionGroups) { + if (!Array.isArray(reactionGroups)) return 0; + const g = reactionGroups.find((rg) => rg.content === "THUMBS_UP"); + return g?.users?.totalCount ?? 0; +} + +function uniqueCommenters(comments, authorLogin) { + const seen = new Set(); + for (const c of comments ?? []) { + const u = c.author; + if (!u || !u.login) continue; + if (u.login === authorLogin) continue; + if (isBot(u)) continue; + seen.add(u.login); + } + return seen.size; +} + +function renderComments(comments) { + return comments + .map( + (c) => + `- **@${c.author?.login ?? "unknown"}** (${c.createdAt}):\n > ${(c.body ?? "").split("\n").join("\n > ")}` + ) + .join("\n"); +} + +function appendComments(body, newComments) { + const heading = "## 💬 Community Discussion"; + let idx = body.indexOf(heading); + if (idx < 0) idx = body.indexOf("## Community Discussion"); + if (idx < 0) { + return body + `\n\n${heading}\n\n` + renderComments(newComments); + } + const after = body.slice(idx); + const nextHeaderMatch = after.slice(heading.length).match(/^##\s+/m); + const insertAt = nextHeaderMatch ? idx + heading.length + nextHeaderMatch.index : body.length; + const insertBlock = "\n\n" + renderComments(newComments) + "\n"; + return body.slice(0, insertAt) + insertBlock + body.slice(insertAt); +} + +export function resyncIdeaFile(text, issue, now = new Date(), opts = {}) { + const meta = parseFrontmatter(text); + if (!meta) return { changed: false, text }; + + const lastSyncedId = Number(meta.last_synced_comment_id ?? 0); + const allComments = issue.comments ?? []; + const newComments = allComments.filter((c) => Number(c.databaseId) > lastSyncedId); + + if (newComments.length === 0) { + return { changed: false, text }; + } + + const body = stripFrontmatter(text); + const newBody = appendComments(body, newComments); + + const newMeta = { + ...meta, + last_synced_at: now.toISOString(), + last_synced_comment_id: Math.max(...newComments.map((c) => Number(c.databaseId))), + snapshot: { + ...(meta.snapshot ?? {}), + thumbs: countThumbs(issue.reactionGroups), + commenters: uniqueCommenters(allComments, issue.author?.login), + labels: (issue.labels ?? []).map((l) => l.name).filter(Boolean), + state: String(issue.state ?? "open").toLowerCase(), + }, + }; + + const out = serializeFrontmatter(newMeta, newBody); + + const needsReclassification = !!( + opts.inNeedDetails && newComments.some((c) => c.author?.login === issue.author?.login) + ); + + return { changed: true, text: out, needsReclassification }; +} diff --git a/tests/unit/feature-triage/resync.test.mjs b/tests/unit/feature-triage/resync.test.mjs new file mode 100644 index 0000000000..efaa9f20d9 --- /dev/null +++ b/tests/unit/feature-triage/resync.test.mjs @@ -0,0 +1,91 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resyncIdeaFile } from "../../../scripts/features/lib/resync.mjs"; +import { serializeFrontmatter } from "../../../scripts/features/lib/frontmatter.mjs"; + +function makeIdeaFile( + meta, + body = "# Feature: X\n\n## Community Discussion\n\nInitial summary.\n" +) { + return serializeFrontmatter(meta, body); +} + +const META_V1 = { + issue: 1046, + last_synced_at: "2026-05-01T00:00:00Z", + last_synced_comment_id: 100, + snapshot: { thumbs: 2, commenters: 1, age_days: 10, state: "open" }, +}; + +describe("resyncIdeaFile", () => { + it("returns unchanged when file has no frontmatter", () => { + const text = "# Plain idea file\nNo frontmatter here."; + const r = resyncIdeaFile(text, { comments: [] }); + assert.equal(r.changed, false); + assert.equal(r.text, text); + }); + + it("appends only new comments (id > last_synced_comment_id)", () => { + const text = makeIdeaFile(META_V1); + const issue = { + comments: [ + { + databaseId: 100, + author: { login: "alice" }, + createdAt: "2026-05-01T00:00:00Z", + body: "old", + }, + { + databaseId: 200, + author: { login: "bob" }, + createdAt: "2026-05-10T00:00:00Z", + body: "NEW comment", + }, + { + databaseId: 300, + author: { login: "carol" }, + createdAt: "2026-05-15T00:00:00Z", + body: "ANOTHER NEW", + }, + ], + reactionGroups: [{ content: "THUMBS_UP", users: { totalCount: 5 } }], + labels: [{ name: "enhancement" }], + state: "OPEN", + }; + const r = resyncIdeaFile(text, issue, new Date("2026-05-19T00:00:00Z")); + assert.equal(r.changed, true); + assert.match(r.text, /NEW comment/); + assert.match(r.text, /ANOTHER NEW/); + assert.doesNotMatch(r.text, /^.*\bold\b.*$/m); + + assert.match(r.text, /last_synced_comment_id:\s*300/); + assert.match(r.text, /thumbs:\s*5/); + }); + + it("returns unchanged when no new comments", () => { + const text = makeIdeaFile(META_V1); + const r = resyncIdeaFile(text, { comments: [{ databaseId: 50 }] }, new Date()); + assert.equal(r.changed, false); + }); + + it("flags needsReclassification when in need_details/ and author replied", () => { + const text = makeIdeaFile(META_V1); + const issue = { + author: { login: "alice" }, + comments: [ + { + databaseId: 200, + author: { login: "alice" }, + createdAt: "2026-05-10T00:00:00Z", + body: "Here are the details!", + }, + ], + reactionGroups: [], + labels: [], + state: "OPEN", + }; + const r = resyncIdeaFile(text, issue, new Date(), { inNeedDetails: true }); + assert.equal(r.changed, true); + assert.equal(r.needsReclassification, true); + }); +});