diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a323102..37dc3e5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -30,3 +30,5 @@ jobs: while IFS= read -r file; do node --check "$file" done < <(find scripts -type f -name '*.mjs' -print) + - name: Run unit tests + run: node --test scripts/test/*.test.mjs diff --git a/scripts/collect-activity.mjs b/scripts/collect-activity.mjs index 4bf4e2c..a90639a 100644 --- a/scripts/collect-activity.mjs +++ b/scripts/collect-activity.mjs @@ -1,61 +1,111 @@ import { activityApi as api, getFile, putFile } from "./lib/github.mjs"; const repository = process.env.GITHUB_REPOSITORY; -const [owner, repo] = repository.split("/"); +if (!repository || !/^[^/]+\/[^/]+$/.test(repository)) { + throw new Error("GITHUB_REPOSITORY must be in owner/repository format."); +} const configured = (process.env.GH_ACTIVITY_REPOS ?? "").split(/[\s,]+/).filter(Boolean); const repositories = [...new Set([repository, ...configured])]; const until = new Date(); const since = new Date(until.getTime() - 24 * 60 * 60 * 1000); const date = until.toISOString().slice(0, 10); const [year, month] = date.split("-"); -const path = `logs/${year}/${month}/${date}.md`; +const path = "logs/" + year + "/" + month + "/" + date + ".md"; const entry = await getFile(path); -if (!entry) throw new Error(`Daily entry does not exist: ${path}`); +if (!entry) throw new Error("Daily entry does not exist: " + path); const sections = []; +const failures = []; +let completedRepositories = 0; + for (const fullName of repositories) { - const [repoOwner, repoName] = fullName.split("/"); - if (!repoOwner || !repoName) { - console.warn(`Skipping invalid repository setting: ${fullName}`); + if (!/^[^/]+\/[^/]+$/.test(fullName)) { + failures.push({ repository: fullName, reason: "invalid owner/repository setting" }); + console.warn("Skipping invalid repository setting: " + fullName); continue; } + + const [repoOwner, repoName] = fullName.split("/"); try { - const query = new URLSearchParams({ since: since.toISOString(), until: until.toISOString(), per_page: "100" }); + const query = new URLSearchParams({ + since: since.toISOString(), + until: until.toISOString(), + per_page: "100", + }); const [commits, pulls, releases] = await Promise.all([ - api(`/repos/${repoOwner}/${repoName}/commits?${query}`), - api(`/repos/${repoOwner}/${repoName}/pulls?state=closed&sort=updated&direction=desc&per_page=100`), - api(`/repos/${repoOwner}/${repoName}/releases?per_page=100`), + api("/repos/" + repoOwner + "/" + repoName + "/commits?" + query), + api("/repos/" + repoOwner + "/" + repoName + "/pulls?state=closed&sort=updated&direction=desc&per_page=100"), + api("/repos/" + repoOwner + "/" + repoName + "/releases?per_page=100"), ]); + + completedRepositories += 1; const recentCommits = commits.filter((item) => { - const authored = item.commit?.author?.date; + const authored = item.commit?.author?.date ?? item.commit?.committer?.date; return authored && authored >= since.toISOString() && authored <= until.toISOString(); }); - const recentPulls = pulls.filter((item) => item.merged_at && item.merged_at >= since.toISOString() && item.merged_at <= until.toISOString()); - const recentReleases = releases.filter((item) => item.published_at && item.published_at >= since.toISOString() && item.published_at <= until.toISOString()); + const recentPulls = pulls.filter((item) => + item.merged_at && item.merged_at >= since.toISOString() && item.merged_at <= until.toISOString() + ); + const recentReleases = releases.filter((item) => + item.published_at && item.published_at >= since.toISOString() && item.published_at <= until.toISOString() + ); + for (const item of recentCommits) { const message = item.commit.message.split("\n")[0].replaceAll("\r", ""); const isLogNote = /\[log\]/i.test(message); const category = isLogNote ? " log note" : ""; - sections.push(`- **${fullName}${category}** — [${escapeMarkdown(message)}](${item.html_url})`); + sections.push("- **" + fullName + category + "** — [" + escapeMarkdown(message) + "](" + item.html_url + ")"); + } + for (const item of recentPulls) { + sections.push("- **" + fullName + " PR** — [#" + item.number + " " + escapeMarkdown(item.title) + "](" + item.html_url + ")"); + } + for (const item of recentReleases) { + sections.push("- **" + fullName + " release** — [" + escapeMarkdown(item.name || item.tag_name) + "](" + item.html_url + ")"); } - for (const item of recentPulls) sections.push(`- **${fullName} PR** — [#${item.number} ${escapeMarkdown(item.title)}](${item.html_url})`); - for (const item of recentReleases) sections.push(`- **${fullName} release** — [${escapeMarkdown(item.name || item.tag_name)}](${item.html_url})`); } catch (error) { - console.warn(`Activity unavailable for ${fullName}: ${error.message}`); + const reason = String(error?.message ?? error).split("\n")[0].slice(0, 240); + failures.push({ repository: fullName, reason }); + console.warn("Activity unavailable for " + fullName + ": " + reason); } } -const activity = sections.length ? sections.join("\n") : "_No GitHub commits, merged pull requests, or releases found in the last 24 hours._"; +if (completedRepositories === 0) { + throw new Error( + "Activity collection failed for every configured repository; preserving the existing activity block. " + + failures.map((item) => item.repository + ": " + item.reason).join("; ") + ); +} + +let activity = sections.length + ? sections.join("\n") + : failures.length + ? "_No activity found in repositories checked successfully._" + : "_No GitHub commits, merged pull requests, or releases found in the last 24 hours._"; +if (failures.length) { + activity += "\n\n> ⚠️ Incomplete activity collection for: " + + failures.map((item) => escapeMarkdown(item.repository) + " (" + escapeMarkdown(item.reason) + ")").join("; "); +} + const start = ""; const end = ""; -const block = `${start}\n### Shipped Today (GitHub activity)\n${activity}\n${end}`; -let content = entry.content; +const block = start + "\n## Shipped Today (GitHub activity)\n" + activity + "\n" + end; const pattern = /[\s\S]*?/; +let content = entry.content; if (pattern.test(content)) content = content.replace(pattern, block); -else content = `${content.trimEnd()}\n\n${block}\n`; -await putFile(path, content, `chore(log): enrich ${date} with GitHub activity`, entry.sha); -console.log(`Recorded ${sections.length} activity item(s) from ${repositories.length} repository/repositories.`); +else content = content.trimEnd() + "\n\n" + block + "\n"; + +await putFile(path, content, "chore(log): enrich " + date + " with GitHub activity", entry.sha); +console.log( + "Recorded " + sections.length + " activity item(s) across " + completedRepositories + + " successfully checked repository/repositories; " + failures.length + " failure(s)." +); function escapeMarkdown(value) { - return String(value).replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]").replaceAll("(", "\\(").replaceAll(")", "\\)").replaceAll("|", "\\|"); + return String(value) + .replaceAll("\\", "\\\\") + .replaceAll("[", "\\[") + .replaceAll("]", "\\]") + .replaceAll("(", "\\(") + .replaceAll(")", "\\)") + .replaceAll("|", "\\|"); } diff --git a/scripts/issue-to-log.mjs b/scripts/issue-to-log.mjs index 919ef6c..e84ecd2 100644 --- a/scripts/issue-to-log.mjs +++ b/scripts/issue-to-log.mjs @@ -1,39 +1,36 @@ import { getFile, getIssue, putFile } from "./lib/github.mjs"; +import { replaceMarkdownSection } from "./lib/markdown.mjs"; const number = Number(process.env.ISSUE_NUMBER); if (!Number.isInteger(number) || number < 1) throw new Error("ISSUE_NUMBER must be a positive integer."); + const issue = await getIssue(number); -const dateMatch = issue.body.match(/^### Log date \(UTC\)\s*\r?\n+([^\r\n]+)/m); +const body = typeof issue.body === "string" ? issue.body : ""; +const dateMatch = body.match(/^### Log date \(UTC\)\s*\r?\n+([^\r\n]+)/m); const requestedDate = dateMatch?.[1]?.trim(); const date = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate ?? "") ? requestedDate : new Date().toISOString().slice(0, 10); -const parsed = new Date(`${date}T00:00:00Z`); -if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== date) throw new Error(`Invalid log date: ${date}`); -const [year, month] = date.split("-"); -const path = `logs/${year}/${month}/${date}.md`; +const parsed = new Date(date + "T00:00:00Z"); +if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== date) { + throw new Error("Invalid log date: " + date); +} +const [year, month] = date.split("-"); +const path = "logs/" + year + "/" + month + "/" + date + ".md"; const fields = Object.fromEntries(["Shipped", "Learned", "Blockers", "Tomorrow"].map((label) => { - const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = issue.body.match(new RegExp(`^### ${escaped}\\s*\\r?\\n+([\\s\\S]*?)(?=^### |$(?![\\s\\S]))`, "m")); - const value = (match?.[1] ?? "").trim(); + const escaped = label.replace(/[.*+?^{}()|[\]\\]/g, "\\$&").replaceAll("$", "\\$"); + const pattern = new RegExp("^### " + escaped + "\\s*\\r?\\n+([\\s\\S]*?)(?=^### |$(?![\\s\\S]))", "m"); + const value = (body.match(pattern)?.[1] ?? "").trim(); return [label, value && value !== "_No response_" ? value : "_Not provided._"]; })); -let file = await getFile(path); -let content = file?.content ?? `# Daily Log — ${date}\n\n**Day:** Captured from issue [#${number}](${issue.html_url})\n`; +const file = await getFile(path); +let content = file?.content ?? "# Daily Log — " + date + "\n\n**Day:** Captured from issue [#" + number + "](" + issue.html_url + ")\n"; for (const [heading, value] of Object.entries(fields)) { - content = replaceSection(content, heading, value); + content = replaceMarkdownSection(content, heading, value); } -if (!content.includes(`[issue #${number}]`)) content += `\n_Source: [issue #${number}](${issue.html_url})_\n`; -await putFile(path, content, `docs(log): save issue #${number} as ${date}`, file?.sha); -console.log(`Saved issue #${number} to ${path}.`); - -function replaceSection(markdown, heading, value) { - const marker = `### ${heading}`; - const start = markdown.indexOf(marker); - if (start < 0) return `${markdown.trimEnd()}\n\n${marker}\n${value}\n`; - const contentStart = markdown.indexOf("\n", start) + 1; - if (contentStart === 0) throw new Error(`Malformed section heading: ${heading}`); - const nextHeading = markdown.indexOf("\n### ", contentStart); - const suffix = nextHeading < 0 ? "" : markdown.slice(nextHeading + 1); - return `${markdown.slice(0, contentStart)}${value}\n\n${suffix}`; +if (!content.includes("[issue #" + number + "]")) { + content = content.trimEnd() + "\n\n_Source: [issue #" + number + "](" + issue.html_url + ")_\n"; } + +await putFile(path, content, "docs(log): save issue #" + number + " as " + date, file?.sha); +console.log("Saved issue #" + number + " to " + path + "."); diff --git a/scripts/lib/markdown.mjs b/scripts/lib/markdown.mjs new file mode 100644 index 0000000..fe0fbbd --- /dev/null +++ b/scripts/lib/markdown.mjs @@ -0,0 +1,49 @@ +export function replaceMarkdownSection(markdown, heading, value) { + if (typeof markdown !== "string") throw new TypeError("markdown must be a string."); + if (typeof heading !== "string" || !heading.trim() || /[\r\n]/.test(heading)) { + throw new TypeError("heading must be a non-empty single line."); + } + if (typeof value !== "string") throw new TypeError("value must be a string."); + + const escaped = heading.replace(/[.*+?^{}()|[\]\\]/g, "\\$&").replaceAll("$", "\\$"); + const headingPattern = new RegExp("^(#{1,6})[ \\t]+" + escaped + "[ \\t]*\\r?$", "gm"); + const matches = [...markdown.matchAll(headingPattern)]; + + for (const duplicate of matches.slice(1).reverse()) { + const level = duplicate[1].length; + let contentStart = duplicate.index + duplicate[0].length; + if (markdown.slice(contentStart, contentStart + 2) === "\r\n") contentStart += 2; + else if (markdown[contentStart] === "\n") contentStart += 1; + + const boundaryPattern = new RegExp( + "^#{1," + level + "}[ \\t]+|^---[ \\t]*$|^$|^$", + "gm", + ); + boundaryPattern.lastIndex = contentStart; + const boundary = boundaryPattern.exec(markdown); + const end = boundary ? boundary.index : markdown.length; + const before = markdown.slice(0, duplicate.index).replace(/\n+$/, ""); + const after = markdown.slice(end).replace(/^\n+/, ""); + markdown = before + (before ? "\n\n" : "") + after; + } + + const match = headingPattern.exec(markdown); + if (!match) { + return markdown.trimEnd() + "\n\n## " + heading + "\n" + value.trim() + "\n"; + } + + const level = match[1].length; + let contentStart = match.index + match[0].length; + if (markdown.slice(contentStart, contentStart + 2) === "\r\n") contentStart += 2; + else if (markdown[contentStart] === "\n") contentStart += 1; + + const boundaryPattern = new RegExp( + "^#{1," + level + "}[ \\t]+|^---[ \\t]*$|^$|^$", + "gm", + ); + boundaryPattern.lastIndex = contentStart; + const boundary = boundaryPattern.exec(markdown); + const end = boundary ? boundary.index : markdown.length; + + return markdown.slice(0, contentStart) + value.trim() + "\n\n" + markdown.slice(end).replace(/^\n+/, ""); +} diff --git a/scripts/test/markdown.test.mjs b/scripts/test/markdown.test.mjs new file mode 100644 index 0000000..49b7b17 --- /dev/null +++ b/scripts/test/markdown.test.mjs @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { replaceMarkdownSection } from "../lib/markdown.mjs"; + +test("updates an existing template section without duplicating its heading", () => { + const source = "# Daily Log\n\n## Shipped\n- \n\n## Learned\n- \n"; + const result = replaceMarkdownSection(source, "Shipped", "- Delivered the capture fix."); + + assert.equal((result.match(/^## Shipped$/gm) ?? []).length, 1); + assert.match(result, /## Shipped\n- Delivered the capture fix\.\n\n## Learned/); +}); + +test("keeps later sections and activity markers outside the replaced section", () => { + const source = [ + "# Daily Log", + "", + "## Tomorrow", + "- ", + "", + "", + "## Shipped Today (GitHub activity)", + "old activity", + "", + "", + "---", + "", + ].join("\n"); + const result = replaceMarkdownSection(source, "Tomorrow", "- Review the PR."); + + assert.match(result, /## Tomorrow\n- Review the PR\./); + assert.match(result, /[\s\S]*old activity/); + assert.match(result, /\n---\n/); +}); + +test("appends a missing section using a level-two heading", () => { + const result = replaceMarkdownSection("# Daily Log\n", "Learned", "- A lesson."); + + assert.match(result, /## Learned\n- A lesson\.\n$/); +}); + +test("matches headings literally rather than treating punctuation as regex syntax", () => { + const source = "# Notes\n\n## What (I) built\n- old\n\n## Next\n- soon\n"; + const result = replaceMarkdownSection(source, "What (I) built", "- new"); + + assert.match(result, /## What \(I\) built\n- new\n\n## Next/); + assert.doesNotMatch(result, /- old/); +}); + +test("removes duplicate sections left by older capture runs", () => { + const source = [ + "# Daily Log", + "", + "## Shipped", + "- old original", + "", + "## Learned", + "- learned", + "", + "### Shipped", + "- stale duplicate", + "", + "### Learned", + "- stale duplicate lesson", + "", + "---", + "", + ].join("\n"); + let result = source; + for (const heading of ["Shipped", "Learned", "Blockers", "Tomorrow"]) { + result = replaceMarkdownSection(result, heading, "- corrected " + heading.toLowerCase()); + } + + assert.equal((result.match(/^#{2,6} Shipped$/gm) ?? []).length, 1); + assert.doesNotMatch(result, /stale duplicate/); + assert.match(result, /## Learned\n- corrected learned/); +});