From 68c85b1fae63facf12f67803e75aa14aa119155d Mon Sep 17 00:00:00 2001 From: Carlo Costanzo Date: Sat, 16 May 2026 15:46:59 -0400 Subject: [PATCH] Add weekly digest workflow --- .github/scripts/weekly-digest.mjs | 498 ++++++++++++++++++++++++++++ .github/workflows/weekly-digest.yml | 31 ++ 2 files changed, 529 insertions(+) create mode 100644 .github/scripts/weekly-digest.mjs create mode 100644 .github/workflows/weekly-digest.yml diff --git a/.github/scripts/weekly-digest.mjs b/.github/scripts/weekly-digest.mjs new file mode 100644 index 00000000..911c43fa --- /dev/null +++ b/.github/scripts/weekly-digest.mjs @@ -0,0 +1,498 @@ +const repository = process.env.GITHUB_REPOSITORY || "CCOSTAN/Home-AssistantConfig"; +const [owner, repo] = repository.split("/"); +const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com"; +const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com"; +const token = process.env.GITHUB_TOKEN || ""; +const dryRun = process.env.DIGEST_DRY_RUN === "1" || process.argv.includes("--dry-run"); + +if (!owner || !repo) { + throw new Error(`GITHUB_REPOSITORY must be owner/repo, got "${repository}"`); +} + +if (!token && !dryRun) { + throw new Error("GITHUB_TOKEN is required unless DIGEST_DRY_RUN=1 is set."); +} + +const now = process.env.DIGEST_NOW ? new Date(process.env.DIGEST_NOW) : new Date(); +const end = process.env.DIGEST_END ? new Date(process.env.DIGEST_END) : now; +const start = process.env.DIGEST_START + ? new Date(process.env.DIGEST_START) + : new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000); + +for (const [name, date] of [["DIGEST_START", start], ["DIGEST_END", end]]) { + if (Number.isNaN(date.getTime())) { + throw new Error(`${name} is not a valid date.`); + } +} + +const defaultHeaders = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "home-assistant-config-weekly-digest", +}; + +if (token) { + defaultHeaders.Authorization = `Bearer ${token}`; +} + +function formatDigestDate(date) { + const day = new Intl.DateTimeFormat("en-US", { day: "numeric", timeZone: "UTC" }).format(date); + const month = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }).format(date); + const year = new Intl.DateTimeFormat("en-US", { year: "numeric", timeZone: "UTC" }).format(date); + return `${day} ${month}, ${year}`; +} + +function formatCountVerb(count, singular, plural) { + return count === 1 ? singular : plural; +} + +function searchRange(field) { + return `${field}:${start.toISOString()}..${end.toISOString()}`; +} + +function isWithinWindow(value) { + if (!value) { + return false; + } + + const date = new Date(value); + return !Number.isNaN(date.getTime()) && date >= start && date <= end; +} + +function markdownLink(label, url) { + const safeLabel = String(label || "unknown").replace(/[[\]]/g, "\\$&"); + return `[${safeLabel}](${url})`; +} + +function userLink(user) { + if (!user || !user.login) { + return "unknown"; + } + + return markdownLink(user.login, user.html_url || `${serverUrl}/${user.login}`); +} + +function issueLine(item, icon) { + return `${icon} #${item.number} ${markdownLink(item.title, item.html_url)}, by ${userLink(item.user)}`; +} + +function commitLine(commit) { + const title = commit.commit?.message?.split("\n")[0] || commit.sha; + const author = commit.author + ? userLink(commit.author) + : commit.commit?.author?.name || "unknown"; + return `:hammer_and_wrench: ${markdownLink(title, commit.html_url)} by ${author}`; +} + +function releaseLine(release) { + const label = release.name || release.tag_name; + return `:bookmark: ${markdownLink(label, release.html_url)}`; +} + +function stargazerLine(star) { + return `:star: ${userLink(star.user)}`; +} + +function sectionDivider(lines) { + lines.push(""); + lines.push(" - - - "); +} + +async function githubRequest(method, path, options = {}) { + const query = options.query || {}; + const url = new URL(`${apiUrl}${path}`); + + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + + const response = await fetch(url, { + method, + headers: { + ...defaultHeaders, + ...(options.headers || {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + if (!response.ok) { + const message = data?.message || text || response.statusText; + throw new Error(`${method} ${url.pathname} failed: ${response.status} ${message}`); + } + + return { data, headers: response.headers }; +} + +async function getAllPages(path, options = {}) { + const results = []; + let page = 1; + + while (true) { + const { data } = await githubRequest("GET", path, { + ...options, + query: { + ...(options.query || {}), + per_page: 100, + page, + }, + }); + + if (!Array.isArray(data) || data.length === 0) { + break; + } + + results.push(...data); + + if (data.length < 100) { + break; + } + + page += 1; + } + + return results; +} + +async function searchIssues(query) { + const results = []; + let page = 1; + + while (true) { + const { data } = await githubRequest("GET", "/search/issues", { + query: { + q: query, + sort: "created", + order: "asc", + per_page: 100, + page, + }, + }); + + const items = data.items || []; + results.push(...items); + + if (items.length < 100 || results.length >= data.total_count) { + break; + } + + page += 1; + } + + return results; +} + +function hasLabel(item, labelName) { + return (item.labels || []).some((label) => label.name === labelName); +} + +async function getCreatedIssueItems() { + const items = await searchIssues(`repo:${owner}/${repo} ${searchRange("created")}`); + return items.filter((item) => !hasLabel(item, "weekly-digest")); +} + +async function getPullRequests() { + const updated = await searchIssues(`repo:${owner}/${repo} is:pr ${searchRange("updated")}`); + const merged = await searchIssues(`repo:${owner}/${repo} is:pr ${searchRange("merged")}`); + const byNumber = new Map(); + + for (const item of [...updated, ...merged]) { + byNumber.set(item.number, item); + } + + const pulls = []; + for (const item of byNumber.values()) { + const { data } = await githubRequest("GET", `/repos/${owner}/${repo}/pulls/${item.number}`); + pulls.push({ + ...item, + merged_at: data.merged_at, + }); + } + + return pulls.sort((a, b) => a.number - b.number); +} + +async function getCommits() { + const commits = await getAllPages(`/repos/${owner}/${repo}/commits`, { + query: { + since: start.toISOString(), + until: end.toISOString(), + }, + }); + + return commits.sort((a, b) => new Date(a.commit.author.date) - new Date(b.commit.author.date)); +} + +async function getReleases() { + const releases = await getAllPages(`/repos/${owner}/${repo}/releases`); + return releases + .filter((release) => isWithinWindow(release.published_at || release.created_at)) + .sort((a, b) => new Date(a.published_at || a.created_at) - new Date(b.published_at || b.created_at)); +} + +async function getStargazers() { + const stars = await getAllPages(`/repos/${owner}/${repo}/stargazers`, { + headers: { + Accept: "application/vnd.github.star+json", + }, + }); + + return stars + .filter((star) => isWithinWindow(star.starred_at)) + .sort((a, b) => new Date(a.starred_at) - new Date(b.starred_at)); +} + +function getReactionTotal(item) { + const reactions = item.reactions || {}; + return (reactions["+1"] || 0) + (reactions.smile || 0) + (reactions.tada || 0) + (reactions.heart || 0); +} + +function appendIssues(lines, createdItems) { + const openItems = createdItems.filter((item) => item.state === "open"); + const closedItems = createdItems.filter((item) => item.state === "closed"); + const likedItem = [...createdItems].sort((a, b) => getReactionTotal(b) - getReactionTotal(a))[0]; + const noisyItem = [...createdItems].sort((a, b) => (b.comments || 0) - (a.comments || 0))[0]; + + lines.push("# ISSUES"); + lines.push(`Last week ${createdItems.length} issues were created.`); + lines.push(`Of these, ${closedItems.length} issues have been closed and ${openItems.length} issues are still open.`); + + if (openItems.length > 0) { + lines.push("## OPEN ISSUES"); + lines.push(...openItems.map((item) => issueLine(item, ":green_heart:"))); + } + + if (closedItems.length > 0) { + lines.push("## CLOSED ISSUES"); + lines.push(...closedItems.map((item) => issueLine(item, ":heart:"))); + } + + if (likedItem && getReactionTotal(likedItem) > 0) { + const reactions = likedItem.reactions || {}; + lines.push("## LIKED ISSUE"); + lines.push(issueLine(likedItem, ":+1:")); + lines.push( + `It received :+1: x${reactions["+1"] || 0}, :smile: x${reactions.smile || 0}, :tada: x${ + reactions.tada || 0 + } and :heart: x${reactions.heart || 0}.`, + ); + } + + if (noisyItem && noisyItem.comments > 0) { + lines.push("## NOISY ISSUE"); + lines.push(issueLine(noisyItem, ":speaker:")); + lines.push(`It received ${noisyItem.comments} comments.`); + } +} + +function appendPullRequests(lines, pulls) { + const openPulls = pulls.filter((pull) => pull.state === "open"); + const mergedPulls = pulls.filter((pull) => isWithinWindow(pull.merged_at)); + const closedPulls = pulls.filter((pull) => pull.state === "closed" && !isWithinWindow(pull.merged_at)); + + lines.push("# PULL REQUESTS"); + + if (pulls.length === 0) { + lines.push("Last week, no pull requests were created, updated or merged."); + return; + } + + lines.push(`Last week, ${pulls.length} pull requests were created, updated or merged.`); + + if (openPulls.length > 0) { + lines.push(`## OPEN PULL ${formatCountVerb(openPulls.length, "REQUEST", "REQUESTS")}`); + lines.push(...openPulls.map((item) => issueLine(item, ":green_heart:"))); + } + + if (closedPulls.length > 0) { + lines.push(`## CLOSED PULL ${formatCountVerb(closedPulls.length, "REQUEST", "REQUESTS")}`); + lines.push(...closedPulls.map((item) => issueLine(item, ":heart:"))); + } + + if (mergedPulls.length > 0) { + lines.push(`## MERGED PULL ${formatCountVerb(mergedPulls.length, "REQUEST", "REQUESTS")}`); + lines.push(...mergedPulls.map((item) => issueLine(item, ":purple_heart:"))); + } +} + +function appendCommits(lines, commits) { + lines.push("# COMMITS"); + + if (commits.length === 0) { + lines.push("Last week there were no commits."); + return; + } + + lines.push(`Last week there were ${commits.length} commits.`); + lines.push(...commits.map(commitLine)); +} + +function appendContributors(lines, commits, createdItems, pulls) { + const contributors = new Map(); + + for (const commit of commits) { + if (commit.author?.login) { + contributors.set(commit.author.login, commit.author); + } + } + + if (contributors.size === 0) { + for (const item of [...createdItems, ...pulls]) { + if (item.user?.login) { + contributors.set(item.user.login, item.user); + } + } + } + + lines.push("# CONTRIBUTORS"); + lines.push(`Last week there ${formatCountVerb(contributors.size, "was", "were")} ${contributors.size} contributors.`); + lines.push(...[...contributors.values()].map((user) => `:bust_in_silhouette: ${userLink(user)}`)); +} + +function appendStargazers(lines, stargazers) { + lines.push("# STARGAZERS"); + + if (stargazers.length === 0) { + lines.push("Last week there were no stagazers."); + return; + } + + lines.push(`Last week there were ${stargazers.length} stagazers.`); + lines.push(...stargazers.slice(0, 75).map(stargazerLine)); + + if (stargazers.length > 75) { + lines.push(`...and ${stargazers.length - 75} more.`); + } + + lines.push("You all are the stars! :star2:"); +} + +function appendReleases(lines, releases) { + lines.push("# RELEASES"); + + if (releases.length === 0) { + lines.push("Last week there were no releases."); + return; + } + + lines.push(`Last week there ${formatCountVerb(releases.length, "was", "were")} ${releases.length} releases.`); + lines.push(...releases.map(releaseLine)); +} + +function buildDigestBody({ createdItems, pulls, commits, stargazers, releases }) { + const repoUrl = `${serverUrl}/${owner}/${repo}`; + const lines = [ + `Here's the **Weekly Digest** for [*${owner}/${repo}*](${repoUrl}):`, + ]; + + sectionDivider(lines); + appendIssues(lines, createdItems); + sectionDivider(lines); + appendPullRequests(lines, pulls); + sectionDivider(lines); + appendCommits(lines, commits); + sectionDivider(lines); + appendContributors(lines, commits, createdItems, pulls); + sectionDivider(lines); + appendStargazers(lines, stargazers); + sectionDivider(lines); + appendReleases(lines, releases); + sectionDivider(lines); + lines.push(""); + lines.push( + `That's all for last week, please :eyes: **Watch** and :star: **Star** the repository [*${owner}/${repo}*](${repoUrl}) to receive next weekly updates. :smiley:`, + ); + lines.push(""); + lines.push( + `*You can also [view all Weekly Digests by clicking here](${repoUrl}/issues?q=is:open+is:issue+label:weekly-digest).* `, + ); + lines.push(""); + lines.push(`> Your [**Weekly Digest**](${repoUrl}/actions/workflows/weekly-digest.yml) bot. :calendar:`); + + return lines.join("\n"); +} + +async function getOpenDigestIssues() { + return getAllPages(`/repos/${owner}/${repo}/issues`, { + query: { + state: "open", + labels: "weekly-digest", + }, + }); +} + +async function createDigestIssue(title, body) { + const { data } = await githubRequest("POST", `/repos/${owner}/${repo}/issues`, { + body: { + title, + body, + labels: ["weekly-digest"], + }, + }); + + return data; +} + +async function closeOldDigestIssue(issue, newIssue) { + const labels = new Set((issue.labels || []).map((label) => label.name)); + labels.add("oldnews"); + + await githubRequest("POST", `/repos/${owner}/${repo}/issues/${issue.number}/comments`, { + body: { + body: `A new weekly digest is available: #${newIssue.number}. Closing this older digest so only the latest one stays open.`, + }, + }); + + await githubRequest("PATCH", `/repos/${owner}/${repo}/issues/${issue.number}`, { + body: { + state: "closed", + state_reason: "completed", + }, + }); + + try { + await githubRequest("PATCH", `/repos/${owner}/${repo}/issues/${issue.number}`, { + body: { + labels: [...labels], + }, + }); + } catch (error) { + console.warn(`Could not add oldnews label to #${issue.number}: ${error.message}`); + } +} + +async function main() { + const title = `Weekly Digest (${formatDigestDate(start)} - ${formatDigestDate(end)})`; + const openDigestIssues = await getOpenDigestIssues(); + const [createdItems, pulls, commits, stargazers, releases] = await Promise.all([ + getCreatedIssueItems(), + getPullRequests(), + getCommits(), + getStargazers(), + getReleases(), + ]); + const body = buildDigestBody({ createdItems, pulls, commits, stargazers, releases }); + + if (dryRun) { + console.log(`DRY RUN: would create "${title}"`); + console.log(body); + console.log(`DRY RUN: would close ${openDigestIssues.length} older digest issues.`); + return; + } + + const newIssue = await createDigestIssue(title, body); + const oldIssues = openDigestIssues.filter((issue) => issue.number !== newIssue.number); + + for (const issue of oldIssues) { + await closeOldDigestIssue(issue, newIssue); + } + + console.log(`Created ${newIssue.html_url}`); + console.log(`Closed ${oldIssues.length} older digest issues.`); +} + +await main(); diff --git a/.github/workflows/weekly-digest.yml b/.github/workflows/weekly-digest.yml new file mode 100644 index 00000000..22525f60 --- /dev/null +++ b/.github/workflows/weekly-digest.yml @@ -0,0 +1,31 @@ +name: Weekly Digest + +on: + schedule: + - cron: "37 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + weekly-digest: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Create weekly digest issue + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + run: node .github/scripts/weekly-digest.mjs