From ecc1332054a47324dda78314c6353eab180c6faa Mon Sep 17 00:00:00 2001 From: "hdcode.dev" <215285761+hdcodedev@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:49:27 +0200 Subject: [PATCH] Sync stats tables to the GitHub profile README --- .github/workflows/update-readme.yml | 23 ++++++++++ __test__/test_render.py | 55 ++++++++++++++++++++++ src/cli.py | 9 +++- src/render.py | 71 ++++++++++++++++++++--------- 4 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 __test__/test_render.py diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index 0d772b8..b77379f 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -26,9 +26,19 @@ jobs: with: python-version: '3.x' + # The profile repo's README shows the same stats tables, so the token + # needs write access to it as well. + - name: Checkout profile repository + uses: actions/checkout@v7 + with: + repository: hdcodedev/hdcodedev + token: ${{ secrets.OSS_CONTRIBUTIONS_TOKEN }} + path: profile + - name: Run generation script env: OSS_CONTRIBUTIONS_TOKEN: ${{ secrets.OSS_CONTRIBUTIONS_TOKEN }} + PROFILE_README: profile/README.md run: | set -e python3 generate_readme.py @@ -58,3 +68,16 @@ jobs: else echo "No changes to commit" fi + + - name: Commit and push profile README + working-directory: profile + run: | + git config --local user.name "github-actions[bot]" + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git add README.md + if ! git diff --cached --quiet; then + git commit -m "Update OSS contribution stats" + git push + else + echo "No profile changes to commit" + fi diff --git a/__test__/test_render.py b/__test__/test_render.py new file mode 100644 index 0000000..7cc18ff --- /dev/null +++ b/__test__/test_render.py @@ -0,0 +1,55 @@ +"""Tests for the profile README stats block (src.render).""" + +import os +import tempfile +import unittest + +from src import model, render +from helpers import grouped_mock, mock_pr + + +def write_readme(text): + handle = tempfile.NamedTemporaryFile('w', suffix='.md', delete=False) + handle.write(text) + handle.close() + return handle.name + + +class TestUpdateProfileReadme(unittest.TestCase): + def setUp(self): + data, featured = grouped_mock([ + mock_pr("fix: a", "u1", 1, "MERGED", "Org/proj", "2026-01-01T00:00:00Z"), + ]) + self.model = model.build_readme_model(data, featured, ['org/proj']) + + def update(self, text): + path = write_readme(text) + try: + render.update_profile_readme(path, self.model) + with open(path) as f: + return f.read() + finally: + os.unlink(path) + + def test_replaces_only_the_marked_block(self): + text = self.update( + "# Hi\n\n\nold\n\n\nMore below.\n" + ) + self.assertEqual( + text, + "# Hi\n\n\n" + f"{render.render_stats(self.model)}" + "\n\nMore below.\n", + ) + + def test_running_twice_gives_the_same_file(self): + once = self.update("\n") + self.assertEqual(self.update(once), once) + + def test_missing_markers_raise(self): + with self.assertRaises(ValueError): + self.update("# Hi\n\n\n") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/src/cli.py b/src/cli.py index 590b098..dccd541 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,10 +1,11 @@ """Command-line entry point.""" +import os import sys from .config import load_config from .model import fetch_from_config, build_readme_model -from .render import generate_json_snapshot, generate_markdown +from .render import generate_json_snapshot, generate_markdown, update_profile_readme def _count_contributions(model): @@ -37,6 +38,12 @@ def main(): generate_markdown(data, featured_repos, model=model) generate_json_snapshot(data, featured_repos, model=model) + # Set by the workflow to a checkout of the GitHub profile repo. + profile_readme = os.environ.get("PROFILE_README") + if profile_readme: + update_profile_readme(profile_readme, model) + print("Profile README stats updated.") + print("Done! README.md and README_DATA.json updated.") return 0 diff --git a/src/render.py b/src/render.py index 23d388a..45855b2 100644 --- a/src/render.py +++ b/src/render.py @@ -15,6 +15,55 @@ def _project_cell(project): ) +def render_stats(model): + """The merged-PR and reported-issue tables, shared by README and profile.""" + lines = [] + + stats = model['stats'] + if stats['projects']: + header = ["Project", *(f"{c['emoji']} {c['label']}" for c in stats['columns']), "Total Merged"] + lines.append(f"| {' | '.join(header)} |") + lines.append(f"| :--- |{' :---: |' * (len(header) - 1)}") + for project in stats['projects']: + cells = [str(project['categories'].get(c['category'], 0)) for c in stats['columns']] + lines.append(f"| {_project_cell(project)} | {' | '.join(cells)} | **{project['total_merged']}** |") + lines.append("") + + issue_stats = model['issue_stats'] + if issue_stats['projects']: + lines.append(f"### {ISSUE_EMOJI} {issue_stats['title']}") + lines.append("") + header = ["Project", *(f"{c['emoji']} {c['label']}" for c in issue_stats['columns']), "Total Reported"] + lines.append(f"| {' | '.join(header)} |") + lines.append(f"| :--- |{' :---: |' * (len(header) - 1)}") + for project in issue_stats['projects']: + cells = [str(project['statuses'].get(c['status'], 0)) for c in issue_stats['columns']] + lines.append(f"| {_project_cell(project)} | {' | '.join(cells)} | **{project['total_reported']}** |") + lines.append("") + + return "".join(f"{line}\n" for line in lines) + + +PROFILE_START = "" +PROFILE_END = "" + + +def update_profile_readme(path, model): + """Replace the block between the oss-stats markers in ``path``. + + Missing markers raise, so a profile README edited by hand fails the run + instead of silently losing its stats. + """ + with open(path) as f: + text = f.read() + before, start, rest = text.partition(PROFILE_START) + _, end, after = rest.partition(PROFILE_END) + if not start or not end: + raise ValueError(f"{path} needs {PROFILE_START} and {PROFILE_END} markers") + with open(path, "w") as f: + f.write(f"{before}{PROFILE_START}\n{render_stats(model)}{PROFILE_END}{after}") + + def generate_markdown(contributions_by_date, featured_repos, output_file="README.md", model=None): """Render the grouped contributions into a Markdown README.""" if model is None: @@ -22,27 +71,7 @@ def generate_markdown(contributions_by_date, featured_repos, output_file="README with open(output_file, "w") as f: f.write("# OSS Contributions\n\n") - - stats = model['stats'] - if stats['projects']: - header = ["Project", *(f"{c['emoji']} {c['label']}" for c in stats['columns']), "Total Merged"] - f.write(f"| {' | '.join(header)} |\n") - f.write(f"| :--- |{' :---: |' * (len(header) - 1)}\n") - for project in stats['projects']: - cells = [str(project['categories'].get(c['category'], 0)) for c in stats['columns']] - f.write(f"| {_project_cell(project)} | {' | '.join(cells)} | **{project['total_merged']}** |\n") - f.write("\n") - - issue_stats = model['issue_stats'] - if issue_stats['projects']: - f.write(f"### {ISSUE_EMOJI} {issue_stats['title']}\n\n") - header = ["Project", *(f"{c['emoji']} {c['label']}" for c in issue_stats['columns']), "Total Reported"] - f.write(f"| {' | '.join(header)} |\n") - f.write(f"| :--- |{' :---: |' * (len(header) - 1)}\n") - for project in issue_stats['projects']: - cells = [str(project['statuses'].get(c['status'], 0)) for c in issue_stats['columns']] - f.write(f"| {_project_cell(project)} | {' | '.join(cells)} | **{project['total_reported']}** |\n") - f.write("\n") + f.write(render_stats(model)) if model['featured_projects']: f.write("## Featured Projects\n\n")