Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
98 changes: 74 additions & 24 deletions scripts/collect-activity.mjs
Original file line number Diff line number Diff line change
@@ -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 = "<!-- ACTIVITY:START -->";
const end = "<!-- ACTIVITY: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 = /<!-- ACTIVITY:START -->[\s\S]*?<!-- ACTIVITY:END -->/;
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("|", "\\|");
}
45 changes: 21 additions & 24 deletions scripts/issue-to-log.mjs
Original file line number Diff line number Diff line change
@@ -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 + ".");
49 changes: 49 additions & 0 deletions scripts/lib/markdown.mjs
Original file line number Diff line number Diff line change
@@ -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]*$|^<!-- ACTIVITY:START -->$|^<!-- ACTIVITY:END -->$",
"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]*$|^<!-- ACTIVITY:START -->$|^<!-- ACTIVITY:END -->$",
"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+/, "");
}
76 changes: 76 additions & 0 deletions scripts/test/markdown.test.mjs
Original file line number Diff line number Diff line change
@@ -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",
"- ",
"",
"<!-- ACTIVITY:START -->",
"## Shipped Today (GitHub activity)",
"old activity",
"<!-- ACTIVITY:END -->",
"",
"---",
"",
].join("\n");
const result = replaceMarkdownSection(source, "Tomorrow", "- Review the PR.");

assert.match(result, /## Tomorrow\n- Review the PR\./);
assert.match(result, /<!-- ACTIVITY:START -->[\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/);
});
Loading