From da5679d78c2faa5359f7de723108889277532919 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 11 Feb 2026 20:36:37 -0500 Subject: [PATCH 01/72] Add empty Zig project --- .github/workflows/main.yml | 24 +- .gitignore | 4 + build.zig | 26 ++ build.zig.zon | 15 + generate_images.py | 136 --------- github_stats.py | 545 ------------------------------------- requirements.txt | 2 - src/main.zig | 6 + 8 files changed, 59 insertions(+), 699 deletions(-) create mode 100644 build.zig create mode 100644 build.zig.zon delete mode 100644 generate_images.py delete mode 100644 github_stats.py delete mode 100644 requirements.txt create mode 100644 src/main.zig diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0e2488133e9..bfa93b79f7e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,8 @@ name: Generate Stats Images on: push: - branches: [ master ] + branches: + - master schedule: - cron: "5 0 * * *" workflow_dispatch: @@ -15,28 +16,19 @@ jobs: runs-on: ubuntu-latest steps: - # Check out repository under $GITHUB_WORKSPACE, so the job can access it - uses: actions/checkout@v3 - - # Run using Python 3.8 for consistency and aiohttp - - name: Set up Python 3.8 - uses: actions/setup-python@v4 + - uses: mlugg/setup-zig@v2 with: - python-version: '3.8' - architecture: 'x64' - cache: 'pip' + version: 0.15.2 - # Install dependencies with `pip` - - name: Install requirements + # TODO: Cache build + - name: Build run: | - python3 -m pip install --upgrade pip setuptools wheel - python3 -m pip install -r requirements.txt + echo TODO - # Generate all statistics images - name: Generate images run: | - python3 --version - python3 generate_images.py + echo TODO env: ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index a9e4db76055..38c3d4792fe 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,7 @@ dmypy.json # PyCharm project files .idea + +# Zig files +.zig-cache +zig-out diff --git a/build.zig b/build.zig new file mode 100644 index 00000000000..cc6b9c6dd23 --- /dev/null +++ b/build.zig @@ -0,0 +1,26 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{ + .preferred_optimize_mode = .ReleaseSafe, + }); + + const exe = b.addExecutable(.{ + .name = "github_stats", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + b.installArtifact(exe); + + const run_step = b.step("run", "Run the app"); + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 00000000000..c7bf1934a9c --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,15 @@ +.{ + .name = .github_stats, + .version = "0.0.0", + .fingerprint = 0x80bb05a632422e37, // Changing this has security and trust implications. + .minimum_zig_version = "0.15.2", + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "LICENSE", + "README.md", + "templates", + }, +} diff --git a/generate_images.py b/generate_images.py deleted file mode 100644 index e800b9357ea..00000000000 --- a/generate_images.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/python3 - -import asyncio -import os -import re - -import aiohttp - -from github_stats import Stats - - -################################################################################ -# Helper Functions -################################################################################ - - -def generate_output_folder() -> None: - """ - Create the output folder if it does not already exist - """ - if not os.path.isdir("generated"): - os.mkdir("generated") - - -################################################################################ -# Individual Image Generation Functions -################################################################################ - - -async def generate_overview(s: Stats) -> None: - """ - Generate an SVG badge with summary statistics - :param s: Represents user's GitHub statistics - """ - with open("templates/overview.svg", "r") as f: - output = f.read() - - output = re.sub("{{ name }}", await s.name, output) - output = re.sub("{{ stars }}", f"{await s.stargazers:,}", output) - output = re.sub("{{ forks }}", f"{await s.forks:,}", output) - output = re.sub("{{ contributions }}", f"{await s.total_contributions:,}", output) - changed = (await s.lines_changed)[0] + (await s.lines_changed)[1] - output = re.sub("{{ lines_changed }}", f"{changed:,}", output) - output = re.sub("{{ views }}", f"{await s.views:,}", output) - output = re.sub("{{ repos }}", f"{len(await s.repos):,}", output) - - generate_output_folder() - with open("generated/overview.svg", "w") as f: - f.write(output) - - -async def generate_languages(s: Stats) -> None: - """ - Generate an SVG badge with summary languages used - :param s: Represents user's GitHub statistics - """ - with open("templates/languages.svg", "r") as f: - output = f.read() - - progress = "" - lang_list = "" - sorted_languages = sorted( - (await s.languages).items(), reverse=True, key=lambda t: t[1].get("size") - ) - delay_between = 150 - for i, (lang, data) in enumerate(sorted_languages): - color = data.get("color") - color = color if color is not None else "#000000" - progress += ( - f'' - ) - lang_list += f""" -
  • - -{lang} -{data.get("prop", 0):0.2f}% -
  • - -""" - - output = re.sub(r"{{ progress }}", progress, output) - output = re.sub(r"{{ lang_list }}", lang_list, output) - - generate_output_folder() - with open("generated/languages.svg", "w") as f: - f.write(output) - - -################################################################################ -# Main Function -################################################################################ - - -async def main() -> None: - """ - Generate all badges - """ - access_token = os.getenv("ACCESS_TOKEN") - if not access_token: - # access_token = os.getenv("GITHUB_TOKEN") - raise Exception("A personal access token is required to proceed!") - user = os.getenv("GITHUB_ACTOR") - if user is None: - raise RuntimeError("Environment variable GITHUB_ACTOR must be set.") - exclude_repos = os.getenv("EXCLUDED") - excluded_repos = ( - {x.strip() for x in exclude_repos.split(",")} if exclude_repos else None - ) - exclude_langs = os.getenv("EXCLUDED_LANGS") - excluded_langs = ( - {x.strip() for x in exclude_langs.split(",")} if exclude_langs else None - ) - # Convert a truthy value to a Boolean - raw_ignore_forked_repos = os.getenv("EXCLUDE_FORKED_REPOS") - ignore_forked_repos = ( - not not raw_ignore_forked_repos - and raw_ignore_forked_repos.strip().lower() != "false" - ) - async with aiohttp.ClientSession() as session: - s = Stats( - user, - access_token, - session, - exclude_repos=excluded_repos, - exclude_langs=excluded_langs, - ignore_forked_repos=ignore_forked_repos, - ) - await asyncio.gather(generate_languages(s), generate_overview(s)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/github_stats.py b/github_stats.py deleted file mode 100644 index b663896e942..00000000000 --- a/github_stats.py +++ /dev/null @@ -1,545 +0,0 @@ -#!/usr/bin/python3 - -import asyncio -import os -from typing import Dict, List, Optional, Set, Tuple, Any, cast - -import aiohttp -import requests - - -############################################################################### -# Main Classes -############################################################################### - - -class Queries(object): - """ - Class with functions to query the GitHub GraphQL (v4) API and the REST (v3) - API. Also includes functions to dynamically generate GraphQL queries. - """ - - def __init__( - self, - username: str, - access_token: str, - session: aiohttp.ClientSession, - max_connections: int = 10, - ): - self.username = username - self.access_token = access_token - self.session = session - self.semaphore = asyncio.Semaphore(max_connections) - - async def query(self, generated_query: str) -> Dict: - """ - Make a request to the GraphQL API using the authentication token from - the environment - :param generated_query: string query to be sent to the API - :return: decoded GraphQL JSON output - """ - headers = { - "Authorization": f"Bearer {self.access_token}", - } - try: - async with self.semaphore: - r_async = await self.session.post( - "https://api.github.com/graphql", - headers=headers, - json={"query": generated_query}, - ) - result = await r_async.json() - if result is not None: - return result - except: - print("aiohttp failed for GraphQL query") - # Fall back on non-async requests - async with self.semaphore: - r_requests = requests.post( - "https://api.github.com/graphql", - headers=headers, - json={"query": generated_query}, - ) - result = r_requests.json() - if result is not None: - return result - return dict() - - async def query_rest(self, path: str, params: Optional[Dict] = None) -> Dict: - """ - Make a request to the REST API - :param path: API path to query - :param params: Query parameters to be passed to the API - :return: deserialized REST JSON output - """ - - for _ in range(60): - headers = { - "Authorization": f"token {self.access_token}", - } - if params is None: - params = dict() - if path.startswith("/"): - path = path[1:] - try: - async with self.semaphore: - r_async = await self.session.get( - f"https://api.github.com/{path}", - headers=headers, - params=tuple(params.items()), - ) - if r_async.status == 202: - # print(f"{path} returned 202. Retrying...") - print(f"A path returned 202. Retrying...") - await asyncio.sleep(2) - continue - - result = await r_async.json() - if result is not None: - return result - except: - print("aiohttp failed for rest query") - # Fall back on non-async requests - async with self.semaphore: - r_requests = requests.get( - f"https://api.github.com/{path}", - headers=headers, - params=tuple(params.items()), - ) - if r_requests.status_code == 202: - print(f"A path returned 202. Retrying...") - await asyncio.sleep(2) - continue - elif r_requests.status_code == 200: - return r_requests.json() - # print(f"There were too many 202s. Data for {path} will be incomplete.") - print("There were too many 202s. Data for this repository will be incomplete.") - return dict() - - @staticmethod - def repos_overview( - contrib_cursor: Optional[str] = None, owned_cursor: Optional[str] = None - ) -> str: - """ - :return: GraphQL query with overview of user repositories - """ - return f"""{{ - viewer {{ - login, - name, - repositories( - first: 100, - orderBy: {{ - field: UPDATED_AT, - direction: DESC - }}, - isFork: false, - after: {"null" if owned_cursor is None else '"'+ owned_cursor +'"'} - ) {{ - pageInfo {{ - hasNextPage - endCursor - }} - nodes {{ - nameWithOwner - stargazers {{ - totalCount - }} - forkCount - languages(first: 10, orderBy: {{field: SIZE, direction: DESC}}) {{ - edges {{ - size - node {{ - name - color - }} - }} - }} - }} - }} - repositoriesContributedTo( - first: 100, - includeUserRepositories: false, - orderBy: {{ - field: UPDATED_AT, - direction: DESC - }}, - contributionTypes: [ - COMMIT, - PULL_REQUEST, - REPOSITORY, - PULL_REQUEST_REVIEW - ] - after: {"null" if contrib_cursor is None else '"'+ contrib_cursor +'"'} - ) {{ - pageInfo {{ - hasNextPage - endCursor - }} - nodes {{ - nameWithOwner - stargazers {{ - totalCount - }} - forkCount - languages(first: 10, orderBy: {{field: SIZE, direction: DESC}}) {{ - edges {{ - size - node {{ - name - color - }} - }} - }} - }} - }} - }} -}} -""" - - @staticmethod - def contrib_years() -> str: - """ - :return: GraphQL query to get all years the user has been a contributor - """ - return """ -query { - viewer { - contributionsCollection { - contributionYears - } - } -} -""" - - @staticmethod - def contribs_by_year(year: str) -> str: - """ - :param year: year to query for - :return: portion of a GraphQL query with desired info for a given year - """ - return f""" - year{year}: contributionsCollection( - from: "{year}-01-01T00:00:00Z", - to: "{int(year) + 1}-01-01T00:00:00Z" - ) {{ - contributionCalendar {{ - totalContributions - }} - }} -""" - - @classmethod - def all_contribs(cls, years: List[str]) -> str: - """ - :param years: list of years to get contributions for - :return: query to retrieve contribution information for all user years - """ - by_years = "\n".join(map(cls.contribs_by_year, years)) - return f""" -query {{ - viewer {{ - {by_years} - }} -}} -""" - - -class Stats(object): - """ - Retrieve and store statistics about GitHub usage. - """ - - def __init__( - self, - username: str, - access_token: str, - session: aiohttp.ClientSession, - exclude_repos: Optional[Set] = None, - exclude_langs: Optional[Set] = None, - ignore_forked_repos: bool = False, - ): - self.username = username - self._ignore_forked_repos = ignore_forked_repos - self._exclude_repos = set() if exclude_repos is None else exclude_repos - self._exclude_langs = set() if exclude_langs is None else exclude_langs - self.queries = Queries(username, access_token, session) - - self._name: Optional[str] = None - self._stargazers: Optional[int] = None - self._forks: Optional[int] = None - self._total_contributions: Optional[int] = None - self._languages: Optional[Dict[str, Any]] = None - self._repos: Optional[Set[str]] = None - self._lines_changed: Optional[Tuple[int, int]] = None - self._views: Optional[int] = None - - async def to_str(self) -> str: - """ - :return: summary of all available statistics - """ - languages = await self.languages_proportional - formatted_languages = "\n - ".join( - [f"{k}: {v:0.4f}%" for k, v in languages.items()] - ) - lines_changed = await self.lines_changed - return f"""Name: {await self.name} -Stargazers: {await self.stargazers:,} -Forks: {await self.forks:,} -All-time contributions: {await self.total_contributions:,} -Repositories with contributions: {len(await self.repos)} -Lines of code added: {lines_changed[0]:,} -Lines of code deleted: {lines_changed[1]:,} -Lines of code changed: {lines_changed[0] + lines_changed[1]:,} -Project page views: {await self.views:,} -Languages: - - {formatted_languages}""" - - async def get_stats(self) -> None: - """ - Get lots of summary statistics using one big query. Sets many attributes - """ - self._stargazers = 0 - self._forks = 0 - self._languages = dict() - self._repos = set() - - exclude_langs_lower = {x.lower() for x in self._exclude_langs} - - next_owned = None - next_contrib = None - while True: - raw_results = await self.queries.query( - Queries.repos_overview( - owned_cursor=next_owned, contrib_cursor=next_contrib - ) - ) - raw_results = raw_results if raw_results is not None else {} - - self._name = raw_results.get("data", {}).get("viewer", {}).get("name", None) - if self._name is None: - self._name = ( - raw_results.get("data", {}) - .get("viewer", {}) - .get("login", "No Name") - ) - - contrib_repos = ( - raw_results.get("data", {}) - .get("viewer", {}) - .get("repositoriesContributedTo", {}) - ) - owned_repos = ( - raw_results.get("data", {}).get("viewer", {}).get("repositories", {}) - ) - - repos = owned_repos.get("nodes", []) - if not self._ignore_forked_repos: - repos += contrib_repos.get("nodes", []) - - for repo in repos: - if repo is None: - continue - name = repo.get("nameWithOwner") - if name in self._repos or name in self._exclude_repos: - continue - self._repos.add(name) - self._stargazers += repo.get("stargazers").get("totalCount", 0) - self._forks += repo.get("forkCount", 0) - - for lang in repo.get("languages", {}).get("edges", []): - name = lang.get("node", {}).get("name", "Other") - languages = await self.languages - if name.lower() in exclude_langs_lower: - continue - if name in languages: - languages[name]["size"] += lang.get("size", 0) - languages[name]["occurrences"] += 1 - else: - languages[name] = { - "size": lang.get("size", 0), - "occurrences": 1, - "color": lang.get("node", {}).get("color"), - } - - if owned_repos.get("pageInfo", {}).get( - "hasNextPage", False - ) or contrib_repos.get("pageInfo", {}).get("hasNextPage", False): - next_owned = owned_repos.get("pageInfo", {}).get( - "endCursor", next_owned - ) - next_contrib = contrib_repos.get("pageInfo", {}).get( - "endCursor", next_contrib - ) - else: - break - - # TODO: Improve languages to scale by number of contributions to - # specific filetypes - langs_total = sum([v.get("size", 0) for v in self._languages.values()]) - for k, v in self._languages.items(): - v["prop"] = 100 * (v.get("size", 0) / langs_total) - - @property - async def name(self) -> str: - """ - :return: GitHub user's name (e.g., Jacob Strieb) - """ - if self._name is not None: - return self._name - await self.get_stats() - assert self._name is not None - return self._name - - @property - async def stargazers(self) -> int: - """ - :return: total number of stargazers on user's repos - """ - if self._stargazers is not None: - return self._stargazers - await self.get_stats() - assert self._stargazers is not None - return self._stargazers - - @property - async def forks(self) -> int: - """ - :return: total number of forks on user's repos - """ - if self._forks is not None: - return self._forks - await self.get_stats() - assert self._forks is not None - return self._forks - - @property - async def languages(self) -> Dict: - """ - :return: summary of languages used by the user - """ - if self._languages is not None: - return self._languages - await self.get_stats() - assert self._languages is not None - return self._languages - - @property - async def languages_proportional(self) -> Dict: - """ - :return: summary of languages used by the user, with proportional usage - """ - if self._languages is None: - await self.get_stats() - assert self._languages is not None - - return {k: v.get("prop", 0) for (k, v) in self._languages.items()} - - @property - async def repos(self) -> Set[str]: - """ - :return: list of names of user's repos - """ - if self._repos is not None: - return self._repos - await self.get_stats() - assert self._repos is not None - return self._repos - - @property - async def total_contributions(self) -> int: - """ - :return: count of user's total contributions as defined by GitHub - """ - if self._total_contributions is not None: - return self._total_contributions - - self._total_contributions = 0 - years = ( - (await self.queries.query(Queries.contrib_years())) - .get("data", {}) - .get("viewer", {}) - .get("contributionsCollection", {}) - .get("contributionYears", []) - ) - by_year = ( - (await self.queries.query(Queries.all_contribs(years))) - .get("data", {}) - .get("viewer", {}) - .values() - ) - for year in by_year: - self._total_contributions += year.get("contributionCalendar", {}).get( - "totalContributions", 0 - ) - return cast(int, self._total_contributions) - - @property - async def lines_changed(self) -> Tuple[int, int]: - """ - :return: count of total lines added, removed, or modified by the user - """ - if self._lines_changed is not None: - return self._lines_changed - additions = 0 - deletions = 0 - for repo in await self.repos: - r = await self.queries.query_rest(f"/repos/{repo}/stats/contributors") - for author_obj in r: - # Handle malformed response from the API by skipping this repo - if not isinstance(author_obj, dict) or not isinstance( - author_obj.get("author", {}), dict - ): - continue - author = author_obj.get("author", {}).get("login", "") - if author != self.username: - continue - - for week in author_obj.get("weeks", []): - additions += week.get("a", 0) - deletions += week.get("d", 0) - - self._lines_changed = (additions, deletions) - return self._lines_changed - - @property - async def views(self) -> int: - """ - Note: only returns views for the last 14 days (as-per GitHub API) - :return: total number of page views the user's projects have received - """ - if self._views is not None: - return self._views - - total = 0 - for repo in await self.repos: - r = await self.queries.query_rest(f"/repos/{repo}/traffic/views") - for view in r.get("views", []): - total += view.get("count", 0) - - self._views = total - return total - - -############################################################################### -# Main Function -############################################################################### - - -async def main() -> None: - """ - Used mostly for testing; this module is not usually run standalone - """ - access_token = os.getenv("ACCESS_TOKEN") - user = os.getenv("GITHUB_ACTOR") - if access_token is None or user is None: - raise RuntimeError( - "ACCESS_TOKEN and GITHUB_ACTOR environment variables cannot be None!" - ) - async with aiohttp.ClientSession() as session: - s = Stats(user, access_token, session) - print(await s.to_str()) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 84b68da70b9..00000000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests -aiohttp \ No newline at end of file diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 00000000000..63397663096 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,6 @@ +const std = @import("std"); + +pub fn main() !void { + // Prints to stderr, ignoring potential errors. + std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); +} From 6a2e061b1534de86f0e159a5075cafaf4a89916e Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 11 Feb 2026 21:14:14 -0500 Subject: [PATCH 02/72] Set up logging --- src/main.zig | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main.zig b/src/main.zig index 63397663096..88c5dc0aa50 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,6 +1,26 @@ +const builtin = @import("builtin"); const std = @import("std"); +pub const std_options: std.Options = .{ + .logFn = logFn, +}; + +var log_level = std.log.default_level; + +fn logFn( + comptime message_level: std.log.Level, + comptime scope: @TypeOf(.enum_literal), + comptime format: []const u8, + args: anytype, +) void { + if (@intFromEnum(message_level) <= @intFromEnum(log_level)) { + std.log.defaultLog(message_level, scope, format, args); + } +} + pub fn main() !void { - // Prints to stderr, ignoring potential errors. - std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); + // TODO: Parse environment variables + // TODO: Parse CLI flags + // TODO: Download statistics to populate data structures + // TODO: Output images from templates } From 64503cc2edd608dfc594be2cd289679666d4e2e4 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 11 Feb 2026 23:36:40 -0500 Subject: [PATCH 03/72] Make an HTTP request --- src/main.zig | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main.zig b/src/main.zig index 88c5dc0aa50..4cf2186609a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -6,6 +6,7 @@ pub const std_options: std.Options = .{ }; var log_level = std.log.default_level; +var allocator: std.mem.Allocator = undefined; fn logFn( comptime message_level: std.log.Level, @@ -19,8 +20,25 @@ fn logFn( } pub fn main() !void { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + defer _ = gpa.deinit(); + allocator = gpa.allocator(); + // TODO: Parse environment variables // TODO: Parse CLI flags + + var client: std.http.Client = .{ .allocator = allocator }; + defer client.deinit(); + var writer = std.Io.Writer.Allocating.init(allocator); + defer writer.deinit(); + _ = try client.fetch(.{ + .location = .{ .url = "https://jstrieb.github.io" }, + .response_writer = &writer.writer, + }); + const body = try writer.toOwnedSlice(); + defer allocator.free(body); + // TODO: Download statistics to populate data structures + // TODO: Output images from templates } From 6ee8c1b377b2b701d5742c79a9dbbe740393749f Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Thu, 12 Feb 2026 00:09:23 -0500 Subject: [PATCH 04/72] Wrap HTTP client with nicer methods --- src/main.zig | 69 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/src/main.zig b/src/main.zig index 4cf2186609a..5d3a8d907d6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -19,6 +19,56 @@ fn logFn( } } +/// Naive, unoptimized HTTP client with .get and .post methods. Simple, and not +/// particularly efficient. +const Client = struct { + client: std.http.Client, + + const Self = @This(); + + pub fn init() Self { + return .{ + .client = .{ .allocator = allocator }, + }; + } + + pub fn deinit(self: *Self) void { + self.client.deinit(); + } + + pub fn get( + self: *Self, + url: []const u8, + headers: ?std.http.Client.Request.Headers, + ) ![]u8 { + var writer = try std.Io.Writer.Allocating.initCapacity(allocator, 1024); + defer writer.deinit(); + _ = try self.client.fetch(.{ + .location = .{ .url = url }, + .response_writer = &writer.writer, + .headers = headers orelse .{}, + }); + return try writer.toOwnedSlice(); + } + + pub fn post( + self: *Self, + url: []const u8, + body: []const u8, + headers: ?std.http.Client.Request.Headers, + ) ![]u8 { + var writer = try std.Io.Writer.Allocating.initCapacity(allocator, 1024); + defer writer.deinit(); + _ = try self.client.fetch(.{ + .location = .{ .url = url }, + .response_writer = &writer.writer, + .payload = body, + .headers = headers orelse .{}, + }); + return try writer.toOwnedSlice(); + } +}; + pub fn main() !void { var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; defer _ = gpa.deinit(); @@ -27,16 +77,19 @@ pub fn main() !void { // TODO: Parse environment variables // TODO: Parse CLI flags - var client: std.http.Client = .{ .allocator = allocator }; + var client: Client = .init(); defer client.deinit(); - var writer = std.Io.Writer.Allocating.init(allocator); - defer writer.deinit(); - _ = try client.fetch(.{ - .location = .{ .url = "https://jstrieb.github.io" }, - .response_writer = &writer.writer, - }); - const body = try writer.toOwnedSlice(); + var body = try client.get("https://jstrieb.github.io", null); + std.log.debug("{s}\n", .{body[0..100]}); + allocator.free(body); + + body = try client.post( + "https://httpbin.org/post", + "{\"a\": 10, \"b\": [ 1, 2, 3 ]}", + .{ .content_type = .{ .override = "application/json" } }, + ); defer allocator.free(body); + std.log.debug("{s}\n", .{body}); // TODO: Download statistics to populate data structures From f893cb4903fbd965cceacafcc080bb7b140a39ba Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Thu, 12 Feb 2026 00:34:25 -0500 Subject: [PATCH 05/72] Arena allocate request bodies --- src/main.zig | 50 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/main.zig b/src/main.zig index 5d3a8d907d6..f0ab31c6ee8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -20,20 +20,30 @@ fn logFn( } /// Naive, unoptimized HTTP client with .get and .post methods. Simple, and not -/// particularly efficient. +/// particularly efficient. Response bodies stay allocated for the lifetime of +/// the client. const Client = struct { + arena: *std.heap.ArenaAllocator, + allocator: std.mem.Allocator, client: std.http.Client, const Self = @This(); - pub fn init() Self { + pub fn init() !Self { + const arena = try allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(allocator); + const a = arena.allocator(); return .{ - .client = .{ .allocator = allocator }, + .arena = arena, + .allocator = a, + .client = .{ .allocator = a }, }; } pub fn deinit(self: *Self) void { self.client.deinit(); + self.arena.deinit(); + allocator.destroy(self.arena); } pub fn get( @@ -41,7 +51,10 @@ const Client = struct { url: []const u8, headers: ?std.http.Client.Request.Headers, ) ![]u8 { - var writer = try std.Io.Writer.Allocating.initCapacity(allocator, 1024); + var writer = try std.Io.Writer.Allocating.initCapacity( + self.allocator, + 1024, + ); defer writer.deinit(); _ = try self.client.fetch(.{ .location = .{ .url = url }, @@ -57,7 +70,10 @@ const Client = struct { body: []const u8, headers: ?std.http.Client.Request.Headers, ) ![]u8 { - var writer = try std.Io.Writer.Allocating.initCapacity(allocator, 1024); + var writer = try std.Io.Writer.Allocating.initCapacity( + self.allocator, + 1024, + ); defer writer.deinit(); _ = try self.client.fetch(.{ .location = .{ .url = url }, @@ -77,21 +93,19 @@ pub fn main() !void { // TODO: Parse environment variables // TODO: Parse CLI flags - var client: Client = .init(); + var client: Client = try .init(); defer client.deinit(); - var body = try client.get("https://jstrieb.github.io", null); - std.log.debug("{s}\n", .{body[0..100]}); - allocator.free(body); - - body = try client.post( - "https://httpbin.org/post", - "{\"a\": 10, \"b\": [ 1, 2, 3 ]}", - .{ .content_type = .{ .override = "application/json" } }, - ); - defer allocator.free(body); - std.log.debug("{s}\n", .{body}); + std.log.debug("{s}\n", .{ + try client.get("https://jstrieb.github.io", null), + }); + std.log.debug("{s}\n", .{ + try client.post( + "https://httpbin.org/post", + "{\"a\": 10, \"b\": [ 1, 2, 3 ]}", + .{ .content_type = .{ .override = "application/json" } }, + ), + }); // TODO: Download statistics to populate data structures - // TODO: Output images from templates } From 50bb92e01aacdc51c603dd8cc4bcdb403bfa2ba5 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Thu, 12 Feb 2026 00:50:43 -0500 Subject: [PATCH 06/72] Simplify get and post args --- src/main.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main.zig b/src/main.zig index f0ab31c6ee8..70167dc3b2e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -49,7 +49,7 @@ const Client = struct { pub fn get( self: *Self, url: []const u8, - headers: ?std.http.Client.Request.Headers, + headers: std.http.Client.Request.Headers, ) ![]u8 { var writer = try std.Io.Writer.Allocating.initCapacity( self.allocator, @@ -59,7 +59,7 @@ const Client = struct { _ = try self.client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.writer, - .headers = headers orelse .{}, + .headers = headers, }); return try writer.toOwnedSlice(); } @@ -68,7 +68,7 @@ const Client = struct { self: *Self, url: []const u8, body: []const u8, - headers: ?std.http.Client.Request.Headers, + headers: std.http.Client.Request.Headers, ) ![]u8 { var writer = try std.Io.Writer.Allocating.initCapacity( self.allocator, @@ -79,7 +79,7 @@ const Client = struct { .location = .{ .url = url }, .response_writer = &writer.writer, .payload = body, - .headers = headers orelse .{}, + .headers = headers, }); return try writer.toOwnedSlice(); } @@ -96,7 +96,7 @@ pub fn main() !void { var client: Client = try .init(); defer client.deinit(); std.log.debug("{s}\n", .{ - try client.get("https://jstrieb.github.io", null), + try client.get("https://jstrieb.github.io", .{}), }); std.log.debug("{s}\n", .{ try client.post( From 73344b9c85a8f9661685ca9b167129ca78563451 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Thu, 12 Feb 2026 00:53:50 -0500 Subject: [PATCH 07/72] Move HTTP client into separate file --- src/http_client.zig | 66 ++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 70 ++------------------------------------------- 2 files changed, 69 insertions(+), 67 deletions(-) create mode 100644 src/http_client.zig diff --git a/src/http_client.zig b/src/http_client.zig new file mode 100644 index 00000000000..e30180239bd --- /dev/null +++ b/src/http_client.zig @@ -0,0 +1,66 @@ +//! Naive, unoptimized HTTP client with .get and .post methods. Simple, and not +//! particularly efficient. Response bodies stay allocated for the lifetime of +//! the client. + +const std = @import("std"); + +gpa: std.mem.Allocator, +arena: *std.heap.ArenaAllocator, +client: std.http.Client, + +const Self = @This(); + +pub fn init(allocator: std.mem.Allocator) !Self { + const arena = try allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(allocator); + const a = arena.allocator(); + return .{ + .gpa = allocator, + .arena = arena, + .client = .{ .allocator = a }, + }; +} + +pub fn deinit(self: *Self) void { + self.client.deinit(); + self.arena.deinit(); + self.gpa.destroy(self.arena); +} + +pub fn get( + self: *Self, + url: []const u8, + headers: std.http.Client.Request.Headers, +) ![]u8 { + var writer = try std.Io.Writer.Allocating.initCapacity( + self.arena.allocator(), + 1024, + ); + defer writer.deinit(); + _ = try self.client.fetch(.{ + .location = .{ .url = url }, + .response_writer = &writer.writer, + .headers = headers, + }); + return try writer.toOwnedSlice(); +} + +pub fn post( + self: *Self, + url: []const u8, + body: []const u8, + headers: std.http.Client.Request.Headers, +) ![]u8 { + var writer = try std.Io.Writer.Allocating.initCapacity( + self.arena.allocator(), + 1024, + ); + defer writer.deinit(); + _ = try self.client.fetch(.{ + .location = .{ .url = url }, + .response_writer = &writer.writer, + .payload = body, + .headers = headers, + }); + return try writer.toOwnedSlice(); +} diff --git a/src/main.zig b/src/main.zig index 70167dc3b2e..83cf3c6a7f7 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,6 +1,8 @@ const builtin = @import("builtin"); const std = @import("std"); +const HttpClient = @import("http_client.zig"); + pub const std_options: std.Options = .{ .logFn = logFn, }; @@ -19,72 +21,6 @@ fn logFn( } } -/// Naive, unoptimized HTTP client with .get and .post methods. Simple, and not -/// particularly efficient. Response bodies stay allocated for the lifetime of -/// the client. -const Client = struct { - arena: *std.heap.ArenaAllocator, - allocator: std.mem.Allocator, - client: std.http.Client, - - const Self = @This(); - - pub fn init() !Self { - const arena = try allocator.create(std.heap.ArenaAllocator); - arena.* = std.heap.ArenaAllocator.init(allocator); - const a = arena.allocator(); - return .{ - .arena = arena, - .allocator = a, - .client = .{ .allocator = a }, - }; - } - - pub fn deinit(self: *Self) void { - self.client.deinit(); - self.arena.deinit(); - allocator.destroy(self.arena); - } - - pub fn get( - self: *Self, - url: []const u8, - headers: std.http.Client.Request.Headers, - ) ![]u8 { - var writer = try std.Io.Writer.Allocating.initCapacity( - self.allocator, - 1024, - ); - defer writer.deinit(); - _ = try self.client.fetch(.{ - .location = .{ .url = url }, - .response_writer = &writer.writer, - .headers = headers, - }); - return try writer.toOwnedSlice(); - } - - pub fn post( - self: *Self, - url: []const u8, - body: []const u8, - headers: std.http.Client.Request.Headers, - ) ![]u8 { - var writer = try std.Io.Writer.Allocating.initCapacity( - self.allocator, - 1024, - ); - defer writer.deinit(); - _ = try self.client.fetch(.{ - .location = .{ .url = url }, - .response_writer = &writer.writer, - .payload = body, - .headers = headers, - }); - return try writer.toOwnedSlice(); - } -}; - pub fn main() !void { var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; defer _ = gpa.deinit(); @@ -93,7 +29,7 @@ pub fn main() !void { // TODO: Parse environment variables // TODO: Parse CLI flags - var client: Client = try .init(); + var client: HttpClient = try .init(allocator); defer client.deinit(); std.log.debug("{s}\n", .{ try client.get("https://jstrieb.github.io", .{}), From 862328501afa9aafebd3a7ce78c8763852433e57 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Mon, 16 Feb 2026 18:25:31 -0500 Subject: [PATCH 08/72] Pull GitHub contribution years from GraphQL API --- src/http_client.zig | 25 +++++++++++++++++++++++- src/main.zig | 46 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/http_client.zig b/src/http_client.zig index e30180239bd..5485bac092c 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -7,10 +7,11 @@ const std = @import("std"); gpa: std.mem.Allocator, arena: *std.heap.ArenaAllocator, client: std.http.Client, +bearer: []const u8, const Self = @This(); -pub fn init(allocator: std.mem.Allocator) !Self { +pub fn init(allocator: std.mem.Allocator, token: []const u8) !Self { const arena = try allocator.create(std.heap.ArenaAllocator); arena.* = std.heap.ArenaAllocator.init(allocator); const a = arena.allocator(); @@ -18,6 +19,7 @@ pub fn init(allocator: std.mem.Allocator) !Self { .gpa = allocator, .arena = arena, .client = .{ .allocator = a }, + .bearer = try std.fmt.allocPrint(a, "Bearer {s}", .{token}), }; } @@ -64,3 +66,24 @@ pub fn post( }); return try writer.toOwnedSlice(); } + +const Query = struct { + query: []const u8, +}; + +pub fn graphql( + self: *Self, + body: []const u8, +) ![]u8 { + var arena = std.heap.ArenaAllocator.init(self.arena.allocator()); + defer arena.deinit(); + const allocator = arena.allocator(); + return try self.post( + "https://api.github.com/graphql", + try std.json.Stringify.valueAlloc(allocator, Query{ .query = body }, .{}), + .{ + .authorization = .{ .override = self.bearer }, + .content_type = .{ .override = "application/json" }, + }, + ); +} diff --git a/src/main.zig b/src/main.zig index 83cf3c6a7f7..bda0406a0ed 100644 --- a/src/main.zig +++ b/src/main.zig @@ -21,6 +21,34 @@ fn logFn( } } +fn years(client: *HttpClient) ![]u32 { + const response = try client.graphql( + \\query { + \\ viewer { + \\ contributionsCollection { + \\ contributionYears + \\ } + \\ } + \\} + ); + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const r = try std.json.parseFromSliceLeaky( + struct { data: struct { viewer: struct { + contributionsCollection: struct { + contributionYears: []u32, + }, + } } }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + ); + return try allocator.dupe( + u32, + r.data.viewer.contributionsCollection.contributionYears, + ); +} + pub fn main() !void { var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; defer _ = gpa.deinit(); @@ -29,18 +57,14 @@ pub fn main() !void { // TODO: Parse environment variables // TODO: Parse CLI flags - var client: HttpClient = try .init(allocator); + var client: HttpClient = try .init( + allocator, + "TODO", + ); defer client.deinit(); - std.log.debug("{s}\n", .{ - try client.get("https://jstrieb.github.io", .{}), - }); - std.log.debug("{s}\n", .{ - try client.post( - "https://httpbin.org/post", - "{\"a\": 10, \"b\": [ 1, 2, 3 ]}", - .{ .content_type = .{ .override = "application/json" } }, - ), - }); + const y = try years(&client); + defer allocator.free(y); + std.debug.print("{any}\n", .{y}); // TODO: Download statistics to populate data structures // TODO: Output images from templates From 322b0bdf758534e766e239ddced5699bad6aa620 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Mon, 16 Feb 2026 18:26:17 -0500 Subject: [PATCH 09/72] Pull initial repo stats --- src/http_client.zig | 7 +- src/main.zig | 206 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 198 insertions(+), 15 deletions(-) diff --git a/src/http_client.zig b/src/http_client.zig index 5485bac092c..fcdcfe950d2 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -69,18 +69,23 @@ pub fn post( const Query = struct { query: []const u8, + variables: ?[]const u8, }; pub fn graphql( self: *Self, body: []const u8, + variables: ?[]const u8, ) ![]u8 { var arena = std.heap.ArenaAllocator.init(self.arena.allocator()); defer arena.deinit(); const allocator = arena.allocator(); return try self.post( "https://api.github.com/graphql", - try std.json.Stringify.valueAlloc(allocator, Query{ .query = body }, .{}), + try std.json.Stringify.valueAlloc(allocator, Query{ + .query = body, + .variables = variables, + }, .{}), .{ .authorization = .{ .override = self.bearer }, .content_type = .{ .override = "application/json" }, diff --git a/src/main.zig b/src/main.zig index bda0406a0ed..2cf4ccc8668 100644 --- a/src/main.zig +++ b/src/main.zig @@ -21,8 +21,51 @@ fn logFn( } } -fn years(client: *HttpClient) ![]u32 { - const response = try client.graphql( +const Language = struct { + name: []const u8, + size: u32, + color: []const u8, + + pub fn deinit(self: @This()) void { + allocator.free(self.name); + allocator.free(self.color); + } +}; + +const Repository = struct { + name: []const u8, + stars: u32, + forks: u32, + languages: ?[]Language, + + pub fn deinit(self: @This()) void { + allocator.free(self.name); + if (self.languages) |languages| { + for (languages) |language| { + language.deinit(); + } + allocator.free(languages); + } + } +}; + +const Statistics = struct { + contributions: u32, + repositories: []Repository, + + pub fn deinit(self: @This()) void { + for (self.repositories) |repository| { + repository.deinit(); + } + allocator.free(self.repositories); + } +}; + +fn get_repos(client: *HttpClient) !Statistics { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var response = try client.graphql( \\query { \\ viewer { \\ contributionsCollection { @@ -30,10 +73,8 @@ fn years(client: *HttpClient) ![]u32 { \\ } \\ } \\} - ); - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - const r = try std.json.parseFromSliceLeaky( + , null); + const years = (try std.json.parseFromSliceLeaky( struct { data: struct { viewer: struct { contributionsCollection: struct { contributionYears: []u32, @@ -42,11 +83,132 @@ fn years(client: *HttpClient) ![]u32 { arena.allocator(), response, .{ .ignore_unknown_fields = true }, - ); - return try allocator.dupe( - u32, - r.data.viewer.contributionsCollection.contributionYears, - ); + )).data.viewer.contributionsCollection.contributionYears; + + var result: Statistics = .{ + .contributions = 0, + .repositories = undefined, + }; + var repositories: std.ArrayList(Repository) = try .initCapacity(allocator, 32); + var seen: std.StringHashMap(bool) = .init(arena.allocator()); + defer seen.deinit(); + + for (years) |year| { + response = try client.graphql( + \\query ($from: DateTime, $to: DateTime) { + \\ viewer { + \\ contributionsCollection(from: $from, to: $to) { + \\ totalRepositoryContributions + \\ totalIssueContributions + \\ totalCommitContributions + \\ totalPullRequestContributions + \\ totalPullRequestReviewContributions + \\ commitContributionsByRepository(maxRepositories: 100) { + \\ repository { + \\ nameWithOwner + \\ stargazerCount + \\ forkCount + \\ languages(first: 100, orderBy: { direction: DESC, field: SIZE }) { + \\ edges { + \\ size + \\ node { + \\ name + \\ color + \\ } + \\ } + \\ } + \\ } + \\ } + \\ } + \\ } + \\} + , + // NOTE: Replace with actual JSON serialization if using more + // complex tyeps. This is fine as long as we're only using numbers. + try std.fmt.allocPrint( + arena.allocator(), + \\{{ + \\ "from": "{d}-01-01T00:00:00Z", + \\ "to": "{d}-01-01T00:00:00Z" + \\}} + , + .{ year, year + 1 }, + ), + ); + const stats = (try std.json.parseFromSliceLeaky( + struct { data: struct { viewer: struct { + contributionsCollection: struct { + totalRepositoryContributions: u32, + totalIssueContributions: u32, + totalCommitContributions: u32, + totalPullRequestContributions: u32, + totalPullRequestReviewContributions: u32, + commitContributionsByRepository: []struct { + repository: struct { + nameWithOwner: []const u8, + stargazerCount: u32, + forkCount: u32, + languages: ?struct { + edges: ?[]struct { + size: u32, + node: struct { + name: []const u8, + color: ?[]const u8, + }, + }, + }, + }, + }, + }, + } } }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + )).data.viewer.contributionsCollection; + + result.contributions += stats.totalRepositoryContributions; + result.contributions += stats.totalIssueContributions; + result.contributions += stats.totalCommitContributions; + result.contributions += stats.totalPullRequestContributions; + result.contributions += stats.totalPullRequestReviewContributions; + + // TODO: if there are 100 ore more repositories, we should subdivide + // the date range in half + + for (stats.commitContributionsByRepository) |x| { + const raw_repo = x.repository; + if (seen.get(raw_repo.nameWithOwner) orelse false) continue; + var repository = Repository{ + .name = try allocator.dupe(u8, raw_repo.nameWithOwner), + .stars = raw_repo.stargazerCount, + .forks = raw_repo.forkCount, + .languages = null, + }; + if (raw_repo.languages) |repo_languages| { + if (repo_languages.edges) |raw_languages| { + repository.languages = try allocator.alloc( + Language, + raw_languages.len, + ); + for (raw_languages, repository.languages.?) |raw, *language| { + language.* = .{ + .name = try allocator.dupe(u8, raw.node.name), + .size = raw.size, + .color = "", + }; + if (raw.node.color) |color| { + language.color = try allocator.dupe(u8, color); + } + } + } + } + try repositories.append(allocator, repository); + try seen.put(raw_repo.nameWithOwner, true); + } + } + + result.repositories = try repositories.toOwnedSlice(allocator); + return result; } pub fn main() !void { @@ -62,10 +224,26 @@ pub fn main() !void { "TODO", ); defer client.deinit(); - const y = try years(&client); - defer allocator.free(y); - std.debug.print("{any}\n", .{y}); + const stats = try get_repos(&client); + defer stats.deinit(); + print(stats); // TODO: Download statistics to populate data structures // TODO: Output images from templates } + +// TODO: Remove +fn print(x: anytype) void { + if (builtin.mode != .Debug) { + @compileError("Do not use JSON print in real code!"); + } + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + std.debug.print("{s}\n", .{ + std.json.Stringify.valueAlloc( + arena.allocator(), + x, + .{ .whitespace = .indent_2 }, + ) catch unreachable, + }); +} From b758e3ad515db384d3291fb4e4d6824f958d7c6e Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Mon, 16 Feb 2026 18:26:51 -0500 Subject: [PATCH 10/72] Minor refactor --- src/main.zig | 69 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/src/main.zig b/src/main.zig index 2cf4ccc8668..bcf79cb0e1d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -53,48 +53,63 @@ const Statistics = struct { contributions: u32, repositories: []Repository, - pub fn deinit(self: @This()) void { + const Self = @This(); + + pub const empty = Self{ + .contributions = 0, + .repositories = undefined, + }; + + pub fn deinit(self: Self) void { for (self.repositories) |repository| { repository.deinit(); } allocator.free(self.repositories); } + + pub fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { + const response = try client.graphql( + \\query { + \\ viewer { + \\ contributionsCollection { + \\ contributionYears + \\ } + \\ } + \\} + , null); + const parsed = try std.json.parseFromSliceLeaky( + struct { + data: struct { + viewer: struct { + contributionsCollection: struct { + contributionYears: []u32, + }, + }, + }, + }, + alloc, + response, + .{ .ignore_unknown_fields = true }, + ); + return parsed + .data + .viewer + .contributionsCollection + .contributionYears; + } }; fn get_repos(client: *HttpClient) !Statistics { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); - var response = try client.graphql( - \\query { - \\ viewer { - \\ contributionsCollection { - \\ contributionYears - \\ } - \\ } - \\} - , null); - const years = (try std.json.parseFromSliceLeaky( - struct { data: struct { viewer: struct { - contributionsCollection: struct { - contributionYears: []u32, - }, - } } }, - arena.allocator(), - response, - .{ .ignore_unknown_fields = true }, - )).data.viewer.contributionsCollection.contributionYears; - - var result: Statistics = .{ - .contributions = 0, - .repositories = undefined, - }; + var result: Statistics = .empty; var repositories: std.ArrayList(Repository) = try .initCapacity(allocator, 32); var seen: std.StringHashMap(bool) = .init(arena.allocator()); defer seen.deinit(); - for (years) |year| { - response = try client.graphql( + for (try Statistics.years(client, arena.allocator())) |year| { + const response = try client.graphql( \\query ($from: DateTime, $to: DateTime) { \\ viewer { \\ contributionsCollection(from: $from, to: $to) { From 5974224102ed9562b2e2bc8cf0113eae4007b80e Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 18 Feb 2026 16:52:51 -0500 Subject: [PATCH 11/72] Pull repo views --- src/http_client.zig | 35 +++++++++++++++++++++++-------- src/main.zig | 51 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/src/http_client.zig b/src/http_client.zig index fcdcfe950d2..a74551b72bf 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -10,6 +10,7 @@ client: std.http.Client, bearer: []const u8, const Self = @This(); +const Response = struct { []const u8, std.http.Status }; pub fn init(allocator: std.mem.Allocator, token: []const u8) !Self { const arena = try allocator.create(std.heap.ArenaAllocator); @@ -33,18 +34,20 @@ pub fn get( self: *Self, url: []const u8, headers: std.http.Client.Request.Headers, -) ![]u8 { + extra_headers: []const std.http.Header, +) !Response { var writer = try std.Io.Writer.Allocating.initCapacity( self.arena.allocator(), 1024, ); defer writer.deinit(); - _ = try self.client.fetch(.{ + const status = (try self.client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.writer, .headers = headers, - }); - return try writer.toOwnedSlice(); + .extra_headers = extra_headers, + })).status; + return .{ try writer.toOwnedSlice(), status }; } pub fn post( @@ -52,19 +55,19 @@ pub fn post( url: []const u8, body: []const u8, headers: std.http.Client.Request.Headers, -) ![]u8 { +) !Response { var writer = try std.Io.Writer.Allocating.initCapacity( self.arena.allocator(), 1024, ); defer writer.deinit(); - _ = try self.client.fetch(.{ + const status = (try self.client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.writer, .payload = body, .headers = headers, - }); - return try writer.toOwnedSlice(); + })).status; + return .{ try writer.toOwnedSlice(), status }; } const Query = struct { @@ -76,7 +79,7 @@ pub fn graphql( self: *Self, body: []const u8, variables: ?[]const u8, -) ![]u8 { +) !Response { var arena = std.heap.ArenaAllocator.init(self.arena.allocator()); defer arena.deinit(); const allocator = arena.allocator(); @@ -92,3 +95,17 @@ pub fn graphql( }, ); } + +pub fn rest( + self: *Self, + url: []const u8, +) !Response { + return try self.get( + url, + .{ + .authorization = .{ .override = self.bearer }, + .content_type = .{ .override = "application/json" }, + }, + &.{.{ .name = "X-GitHub-Api-Version", .value = "2022-11-28" }}, + ); +} diff --git a/src/main.zig b/src/main.zig index bcf79cb0e1d..ef62ef0340e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -37,6 +37,7 @@ const Repository = struct { stars: u32, forks: u32, languages: ?[]Language, + views: u32, pub fn deinit(self: @This()) void { allocator.free(self.name); @@ -68,7 +69,7 @@ const Statistics = struct { } pub fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { - const response = try client.graphql( + const response, const status = try client.graphql( \\query { \\ viewer { \\ contributionsCollection { @@ -77,6 +78,7 @@ const Statistics = struct { \\ } \\} , null); + if (status != .ok) return error.RequestFailed; const parsed = try std.json.parseFromSliceLeaky( struct { data: struct { @@ -104,12 +106,13 @@ fn get_repos(client: *HttpClient) !Statistics { defer arena.deinit(); var result: Statistics = .empty; - var repositories: std.ArrayList(Repository) = try .initCapacity(allocator, 32); + var repositories: std.ArrayList(Repository) = + try .initCapacity(allocator, 32); var seen: std.StringHashMap(bool) = .init(arena.allocator()); defer seen.deinit(); for (try Statistics.years(client, arena.allocator())) |year| { - const response = try client.graphql( + var response, var status = try client.graphql( \\query ($from: DateTime, $to: DateTime) { \\ viewer { \\ contributionsCollection(from: $from, to: $to) { @@ -123,7 +126,10 @@ fn get_repos(client: *HttpClient) !Statistics { \\ nameWithOwner \\ stargazerCount \\ forkCount - \\ languages(first: 100, orderBy: { direction: DESC, field: SIZE }) { + \\ languages( + \\ first: 100, + \\ orderBy: { direction: DESC, field: SIZE } + \\ ) { \\ edges { \\ size \\ node { @@ -150,6 +156,7 @@ fn get_repos(client: *HttpClient) !Statistics { .{ year, year + 1 }, ), ); + if (status != .ok) return error.RequestFailed; const stats = (try std.json.parseFromSliceLeaky( struct { data: struct { viewer: struct { contributionsCollection: struct { @@ -198,6 +205,7 @@ fn get_repos(client: *HttpClient) !Statistics { .stars = raw_repo.stargazerCount, .forks = raw_repo.forkCount, .languages = null, + .views = 0, }; if (raw_repo.languages) |repo_languages| { if (repo_languages.edges) |raw_languages| { @@ -205,7 +213,10 @@ fn get_repos(client: *HttpClient) !Statistics { Language, raw_languages.len, ); - for (raw_languages, repository.languages.?) |raw, *language| { + for ( + raw_languages, + repository.languages.?, + ) |raw, *language| { language.* = .{ .name = try allocator.dupe(u8, raw.node.name), .size = raw.size, @@ -217,12 +228,40 @@ fn get_repos(client: *HttpClient) !Statistics { } } } + response, status = try client.rest( + try std.mem.concat( + arena.allocator(), + u8, + &.{ + "https://api.github.com/repos/", + raw_repo.nameWithOwner, + "/traffic/views", + }, + ), + ); + if (status == .ok) { + repository.views = (try std.json.parseFromSliceLeaky( + struct { count: u32 }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + )).count; + } try repositories.append(allocator, repository); try seen.put(raw_repo.nameWithOwner, true); } } result.repositories = try repositories.toOwnedSlice(allocator); + std.sort.pdq(Repository, result.repositories, {}, struct { + pub fn lessThanFn(_: void, lhs: Repository, rhs: Repository) bool { + if (rhs.views == lhs.views) { + return rhs.stars + rhs.forks < lhs.stars + lhs.forks; + } + return rhs.views < lhs.views; + } + }.lessThanFn); + return result; } @@ -236,7 +275,7 @@ pub fn main() !void { var client: HttpClient = try .init( allocator, - "TODO", + "", ); defer client.deinit(); const stats = try get_repos(&client); From a6192d5ca2faa371609e90804dc8b68c8aaae7b1 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 18 Feb 2026 18:41:53 -0500 Subject: [PATCH 12/72] Add basic logging --- src/main.zig | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/main.zig b/src/main.zig index ef62ef0340e..3c2ffafc64f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -7,7 +7,10 @@ pub const std_options: std.Options = .{ .logFn = logFn, }; -var log_level = std.log.default_level; +var log_level: std.log.Level = switch (builtin.mode) { + .Debug => .debug, + else => .warn, +}; var allocator: std.mem.Allocator = undefined; fn logFn( @@ -69,6 +72,7 @@ const Statistics = struct { } pub fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { + std.log.info("Getting contribution years...", .{}); const response, const status = try client.graphql( \\query { \\ viewer { @@ -78,7 +82,10 @@ const Statistics = struct { \\ } \\} , null); - if (status != .ok) return error.RequestFailed; + if (status != .ok) { + std.log.err("Failed to get contribution years ({any})", .{status}); + return error.RequestFailed; + } const parsed = try std.json.parseFromSliceLeaky( struct { data: struct { @@ -112,6 +119,7 @@ fn get_repos(client: *HttpClient) !Statistics { defer seen.deinit(); for (try Statistics.years(client, arena.allocator())) |year| { + std.log.info("Getting data from year {d}...", .{year}); var response, var status = try client.graphql( \\query ($from: DateTime, $to: DateTime) { \\ viewer { @@ -156,7 +164,13 @@ fn get_repos(client: *HttpClient) !Statistics { .{ year, year + 1 }, ), ); - if (status != .ok) return error.RequestFailed; + if (status != .ok) { + std.log.err( + "Failed to get data from year {d} ({any})", + .{ year, status }, + ); + return error.RequestFailed; + } const stats = (try std.json.parseFromSliceLeaky( struct { data: struct { viewer: struct { contributionsCollection: struct { @@ -187,6 +201,10 @@ fn get_repos(client: *HttpClient) !Statistics { response, .{ .ignore_unknown_fields = true }, )).data.viewer.contributionsCollection; + std.log.info( + "Parsed data for {d} total repositories in {d}", + .{ stats.commitContributionsByRepository.len, year }, + ); result.contributions += stats.totalRepositoryContributions; result.contributions += stats.totalIssueContributions; @@ -199,7 +217,13 @@ fn get_repos(client: *HttpClient) !Statistics { for (stats.commitContributionsByRepository) |x| { const raw_repo = x.repository; - if (seen.get(raw_repo.nameWithOwner) orelse false) continue; + if (seen.get(raw_repo.nameWithOwner) orelse false) { + std.log.info( + "Skipping {s} (seen)", + .{raw_repo.nameWithOwner}, + ); + continue; + } var repository = Repository{ .name = try allocator.dupe(u8, raw_repo.nameWithOwner), .stars = raw_repo.stargazerCount, @@ -228,6 +252,10 @@ fn get_repos(client: *HttpClient) !Statistics { } } } + std.log.info( + "Getting views for {s}...", + .{raw_repo.nameWithOwner}, + ); response, status = try client.rest( try std.mem.concat( arena.allocator(), @@ -246,6 +274,11 @@ fn get_repos(client: *HttpClient) !Statistics { response, .{ .ignore_unknown_fields = true }, )).count; + } else { + std.log.warn( + "Failed to get views for {s} ({any})", + .{ raw_repo.nameWithOwner, status }, + ); } try repositories.append(allocator, repository); try seen.put(raw_repo.nameWithOwner, true); From 2b9e70bd171a906e35212d402b714ab726bc5a29 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Thu, 19 Feb 2026 12:02:42 -0500 Subject: [PATCH 13/72] Get lines changed --- src/main.zig | 110 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 9 deletions(-) diff --git a/src/main.zig b/src/main.zig index 3c2ffafc64f..c4e122af435 100644 --- a/src/main.zig +++ b/src/main.zig @@ -12,6 +12,7 @@ var log_level: std.log.Level = switch (builtin.mode) { else => .warn, }; var allocator: std.mem.Allocator = undefined; +var user: []const u8 = undefined; fn logFn( comptime message_level: std.log.Level, @@ -41,6 +42,7 @@ const Repository = struct { forks: u32, languages: ?[]Language, views: u32, + lines_changed: u32, pub fn deinit(self: @This()) void { allocator.free(self.name); @@ -83,7 +85,10 @@ const Statistics = struct { \\} , null); if (status != .ok) { - std.log.err("Failed to get contribution years ({any})", .{status}); + std.log.err( + "Failed to get contribution years ({?s})", + .{status.phrase()}, + ); return error.RequestFailed; } const parsed = try std.json.parseFromSliceLeaky( @@ -119,7 +124,7 @@ fn get_repos(client: *HttpClient) !Statistics { defer seen.deinit(); for (try Statistics.years(client, arena.allocator())) |year| { - std.log.info("Getting data from year {d}...", .{year}); + std.log.info("Getting data from {d}...", .{year}); var response, var status = try client.graphql( \\query ($from: DateTime, $to: DateTime) { \\ viewer { @@ -166,8 +171,8 @@ fn get_repos(client: *HttpClient) !Statistics { ); if (status != .ok) { std.log.err( - "Failed to get data from year {d} ({any})", - .{ year, status }, + "Failed to get data from {d} ({?s})", + .{ year, status.phrase() }, ); return error.RequestFailed; } @@ -202,7 +207,7 @@ fn get_repos(client: *HttpClient) !Statistics { .{ .ignore_unknown_fields = true }, )).data.viewer.contributionsCollection; std.log.info( - "Parsed data for {d} total repositories in {d}", + "Parsed {d} total repositories from {d}", .{ stats.commitContributionsByRepository.len, year }, ); @@ -219,7 +224,7 @@ fn get_repos(client: *HttpClient) !Statistics { const raw_repo = x.repository; if (seen.get(raw_repo.nameWithOwner) orelse false) { std.log.info( - "Skipping {s} (seen)", + "Skipping view count for {s} (seen)", .{raw_repo.nameWithOwner}, ); continue; @@ -230,6 +235,7 @@ fn get_repos(client: *HttpClient) !Statistics { .forks = raw_repo.forkCount, .languages = null, .views = 0, + .lines_changed = 0, }; if (raw_repo.languages) |repo_languages| { if (repo_languages.edges) |raw_languages| { @@ -276,8 +282,8 @@ fn get_repos(client: *HttpClient) !Statistics { )).count; } else { std.log.warn( - "Failed to get views for {s} ({any})", - .{ raw_repo.nameWithOwner, status }, + "Failed to get views for {s} ({?s})", + .{ raw_repo.nameWithOwner, status.phrase() }, ); } try repositories.append(allocator, repository); @@ -295,6 +301,92 @@ fn get_repos(client: *HttpClient) !Statistics { } }.lessThanFn); + const T = struct { + repo: *Repository, + delay: i64, + timestamp: i64, + }; + var q: std.PriorityQueue(T, void, struct { + pub fn compareFn(_: void, lhs: T, rhs: T) std.math.Order { + return std.math.order(lhs.timestamp, rhs.timestamp); + } + }.compareFn) = .init(arena.allocator(), {}); + defer q.deinit(); + for (result.repositories) |*repo| { + try q.add(.{ + .repo = repo, + .delay = 16, + .timestamp = std.time.timestamp(), + }); + } + while (q.count() > 0) { + var item = q.remove(); + const now = std.time.timestamp(); + if (item.timestamp > now) { + std.Thread.sleep( + @as(u64, @intCast( + item.timestamp - now, + )) * std.time.ns_per_s, + ); + } + std.log.info( + "Trying to get lines of code changed for {s}...", + .{item.repo.name}, + ); + const response, const status = try client.rest( + try std.mem.concat( + arena.allocator(), + u8, + &.{ + "https://api.github.com/repos/", + item.repo.name, + "/stats/contributors", + }, + ), + ); + switch (status) { + .ok => { + const authors = (try std.json.parseFromSliceLeaky( + []struct { + author: struct { login: []const u8 }, + weeks: []struct { + a: u32, + d: u32, + }, + }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + )); + for (authors) |o| { + if (!std.mem.eql(u8, o.author.login, user)) { + continue; + } + for (o.weeks) |week| { + item.repo.lines_changed += week.a; + item.repo.lines_changed += week.d; + } + } + std.log.info( + "Got {d} lines changed by {s} in {s}", + .{ item.repo.lines_changed, user, item.repo.name }, + ); + }, + .accepted => { + item.timestamp = std.time.timestamp() + item.delay; + item.delay *= 2; + try q.add(item); + }, + else => { + std.log.err( + "Failed to get contribution data for {s} ({?s})", + .{ item.repo.name, status.phrase() }, + ); + return error.RequestFailed; + }, + } + } + return result; } @@ -310,12 +402,12 @@ pub fn main() !void { allocator, "", ); + user = ""; defer client.deinit(); const stats = try get_repos(&client); defer stats.deinit(); print(stats); - // TODO: Download statistics to populate data structures // TODO: Output images from templates } From b6ef8ff5bfe28ca798b07af46788d80ac44cf443 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Mon, 23 Feb 2026 21:35:38 -0500 Subject: [PATCH 14/72] Deallocate correctly on errors in get_repos --- src/main.zig | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main.zig b/src/main.zig index c4e122af435..32bd0cca593 100644 --- a/src/main.zig +++ b/src/main.zig @@ -56,21 +56,18 @@ const Repository = struct { }; const Statistics = struct { - contributions: u32, - repositories: []Repository, + contributions: u32 = 0, + repositories: ?[]Repository = null, const Self = @This(); - pub const empty = Self{ - .contributions = 0, - .repositories = undefined, - }; - pub fn deinit(self: Self) void { - for (self.repositories) |repository| { - repository.deinit(); + if (self.repositories) |repositories| { + for (repositories) |repository| { + repository.deinit(); + } + allocator.free(repositories); } - allocator.free(self.repositories); } pub fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { @@ -117,9 +114,19 @@ fn get_repos(client: *HttpClient) !Statistics { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); - var result: Statistics = .empty; + var result: Statistics = .{}; var repositories: std.ArrayList(Repository) = try .initCapacity(allocator, 32); + errdefer { + if (result.repositories) |_| { + result.deinit(); + } else { + for (repositories.items) |repo| { + repo.deinit(); + } + repositories.deinit(allocator); + } + } var seen: std.StringHashMap(bool) = .init(arena.allocator()); defer seen.deinit(); @@ -292,7 +299,7 @@ fn get_repos(client: *HttpClient) !Statistics { } result.repositories = try repositories.toOwnedSlice(allocator); - std.sort.pdq(Repository, result.repositories, {}, struct { + std.sort.pdq(Repository, result.repositories.?, {}, struct { pub fn lessThanFn(_: void, lhs: Repository, rhs: Repository) bool { if (rhs.views == lhs.views) { return rhs.stars + rhs.forks < lhs.stars + lhs.forks; @@ -312,7 +319,7 @@ fn get_repos(client: *HttpClient) !Statistics { } }.compareFn) = .init(arena.allocator(), {}); defer q.deinit(); - for (result.repositories) |*repo| { + for (result.repositories.?) |*repo| { try q.add(.{ .repo = repo, .delay = 16, From 312e45d8ed9f33548988efb54f2493713c9e1cb2 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Feb 2026 01:56:54 -0500 Subject: [PATCH 15/72] Work around keep alive timeouts --- src/http_client.zig | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/http_client.zig b/src/http_client.zig index a74551b72bf..4d1df4dbf31 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -8,9 +8,11 @@ gpa: std.mem.Allocator, arena: *std.heap.ArenaAllocator, client: std.http.Client, bearer: []const u8, +last_request: ?i64 = null, const Self = @This(); const Response = struct { []const u8, std.http.Status }; +const KEEP_ALIVE_TIMEOUT: i64 = 16; pub fn init(allocator: std.mem.Allocator, token: []const u8) !Self { const arena = try allocator.create(std.heap.ArenaAllocator); @@ -41,12 +43,20 @@ pub fn get( 1024, ); defer writer.deinit(); + const now = std.time.timestamp(); const status = (try self.client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.writer, .headers = headers, .extra_headers = extra_headers, + // Work around failures from keep alive connections closing after + // timeout and not being automatically reopened by Zig + .keep_alive = if (self.last_request) |last| + now - last > KEEP_ALIVE_TIMEOUT + else + true, })).status; + self.last_request = now; return .{ try writer.toOwnedSlice(), status }; } @@ -61,12 +71,20 @@ pub fn post( 1024, ); defer writer.deinit(); + const now = std.time.timestamp(); const status = (try self.client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.writer, .payload = body, .headers = headers, + // Work around failures from keep alive connections closing after + // timeout and not being automatically reopened by Zig + .keep_alive = if (self.last_request) |last| + now - last > KEEP_ALIVE_TIMEOUT + else + true, })).status; + self.last_request = now; return .{ try writer.toOwnedSlice(), status }; } From 82c4407b3240bcb2db5ceee6315eceaa99ff19ae Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Feb 2026 01:57:32 -0500 Subject: [PATCH 16/72] Tweak API request delay values --- src/main.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main.zig b/src/main.zig index 32bd0cca593..fbc8473addf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -322,7 +322,7 @@ fn get_repos(client: *HttpClient) !Statistics { for (result.repositories.?) |*repo| { try q.add(.{ .repo = repo, - .delay = 16, + .delay = 2, .timestamp = std.time.timestamp(), }); } @@ -330,6 +330,10 @@ fn get_repos(client: *HttpClient) !Statistics { var item = q.remove(); const now = std.time.timestamp(); if (item.timestamp > now) { + std.log.debug("Sleeping for {d}s. Waiting for {d} repos.", .{ + item.timestamp - now, + q.count() + 1, + }); std.Thread.sleep( @as(u64, @intCast( item.timestamp - now, @@ -381,7 +385,8 @@ fn get_repos(client: *HttpClient) !Statistics { }, .accepted => { item.timestamp = std.time.timestamp() + item.delay; - item.delay *= 2; + const _delay: f64 = @floatFromInt(item.delay); + item.delay = @intFromFloat(_delay * 1.5); try q.add(item); }, else => { From ed4cca88e727d004d785a71c3eadbc3d5419b7a9 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Feb 2026 10:43:50 -0500 Subject: [PATCH 17/72] Partial refactor to split up big function --- src/main.zig | 72 +++++++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/src/main.zig b/src/main.zig index fbc8473addf..bcabf3babae 100644 --- a/src/main.zig +++ b/src/main.zig @@ -56,18 +56,16 @@ const Repository = struct { }; const Statistics = struct { + repositories: []Repository, contributions: u32 = 0, - repositories: ?[]Repository = null, const Self = @This(); pub fn deinit(self: Self) void { - if (self.repositories) |repositories| { - for (repositories) |repository| { - repository.deinit(); - } - allocator.free(repositories); + for (self.repositories) |repository| { + repository.deinit(); } + allocator.free(self.repositories); } pub fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { @@ -110,22 +108,18 @@ const Statistics = struct { } }; -fn get_repos(client: *HttpClient) !Statistics { - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - - var result: Statistics = .{}; +fn repo_list( + arena: *std.heap.ArenaAllocator, + client: *HttpClient, +) !struct { u32, []Repository } { + var contributions: u32 = 0; var repositories: std.ArrayList(Repository) = try .initCapacity(allocator, 32); errdefer { - if (result.repositories) |_| { - result.deinit(); - } else { - for (repositories.items) |repo| { - repo.deinit(); - } - repositories.deinit(allocator); + for (repositories.items) |repo| { + repo.deinit(); } + repositories.deinit(allocator); } var seen: std.StringHashMap(bool) = .init(arena.allocator()); defer seen.deinit(); @@ -218,11 +212,11 @@ fn get_repos(client: *HttpClient) !Statistics { .{ stats.commitContributionsByRepository.len, year }, ); - result.contributions += stats.totalRepositoryContributions; - result.contributions += stats.totalIssueContributions; - result.contributions += stats.totalCommitContributions; - result.contributions += stats.totalPullRequestContributions; - result.contributions += stats.totalPullRequestReviewContributions; + contributions += stats.totalRepositoryContributions; + contributions += stats.totalIssueContributions; + contributions += stats.totalCommitContributions; + contributions += stats.totalPullRequestContributions; + contributions += stats.totalPullRequestReviewContributions; // TODO: if there are 100 ore more repositories, we should subdivide // the date range in half @@ -288,7 +282,7 @@ fn get_repos(client: *HttpClient) !Statistics { .{ .ignore_unknown_fields = true }, )).count; } else { - std.log.warn( + std.log.info( "Failed to get views for {s} ({?s})", .{ raw_repo.nameWithOwner, status.phrase() }, ); @@ -298,8 +292,8 @@ fn get_repos(client: *HttpClient) !Statistics { } } - result.repositories = try repositories.toOwnedSlice(allocator); - std.sort.pdq(Repository, result.repositories.?, {}, struct { + const list = try repositories.toOwnedSlice(allocator); + std.sort.pdq(Repository, list, {}, struct { pub fn lessThanFn(_: void, lhs: Repository, rhs: Repository) bool { if (rhs.views == lhs.views) { return rhs.stars + rhs.forks < lhs.stars + lhs.forks; @@ -308,6 +302,17 @@ fn get_repos(client: *HttpClient) !Statistics { } }.lessThanFn); + return .{ contributions, list }; +} + +fn get_repos(client: *HttpClient) !Statistics { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var result: Statistics = .{ .repositories = undefined }; + result.contributions, result.repositories = try repo_list(&arena, client); + errdefer result.deinit(); + const T = struct { repo: *Repository, delay: i64, @@ -319,7 +324,7 @@ fn get_repos(client: *HttpClient) !Statistics { } }.compareFn) = .init(arena.allocator(), {}); defer q.deinit(); - for (result.repositories.?) |*repo| { + for (result.repositories) |*repo| { try q.add(.{ .repo = repo, .delay = 2, @@ -330,15 +335,12 @@ fn get_repos(client: *HttpClient) !Statistics { var item = q.remove(); const now = std.time.timestamp(); if (item.timestamp > now) { + const delay: u64 = @intCast(item.timestamp - now); std.log.debug("Sleeping for {d}s. Waiting for {d} repos.", .{ - item.timestamp - now, + delay, q.count() + 1, }); - std.Thread.sleep( - @as(u64, @intCast( - item.timestamp - now, - )) * std.time.ns_per_s, - ); + std.Thread.sleep(delay * std.time.ns_per_s); } std.log.info( "Trying to get lines of code changed for {s}...", @@ -385,8 +387,8 @@ fn get_repos(client: *HttpClient) !Statistics { }, .accepted => { item.timestamp = std.time.timestamp() + item.delay; - const _delay: f64 = @floatFromInt(item.delay); - item.delay = @intFromFloat(_delay * 1.5); + const old_delay: f64 = @floatFromInt(item.delay); + item.delay = @intFromFloat(old_delay * 1.5); try q.add(item); }, else => { From e07c2886acf8a3bc590e5e97152e7269ad34175b Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Feb 2026 20:09:39 -0500 Subject: [PATCH 18/72] Refactor statistics struct into separate file --- src/main.zig | 382 +------------------------------------------- src/statistics.zig | 385 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+), 380 deletions(-) create mode 100644 src/statistics.zig diff --git a/src/main.zig b/src/main.zig index bcabf3babae..243b78ceb8b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,6 +2,7 @@ const builtin = @import("builtin"); const std = @import("std"); const HttpClient = @import("http_client.zig"); +const Statistics = @import("statistics.zig"); pub const std_options: std.Options = .{ .logFn = logFn, @@ -25,385 +26,6 @@ fn logFn( } } -const Language = struct { - name: []const u8, - size: u32, - color: []const u8, - - pub fn deinit(self: @This()) void { - allocator.free(self.name); - allocator.free(self.color); - } -}; - -const Repository = struct { - name: []const u8, - stars: u32, - forks: u32, - languages: ?[]Language, - views: u32, - lines_changed: u32, - - pub fn deinit(self: @This()) void { - allocator.free(self.name); - if (self.languages) |languages| { - for (languages) |language| { - language.deinit(); - } - allocator.free(languages); - } - } -}; - -const Statistics = struct { - repositories: []Repository, - contributions: u32 = 0, - - const Self = @This(); - - pub fn deinit(self: Self) void { - for (self.repositories) |repository| { - repository.deinit(); - } - allocator.free(self.repositories); - } - - pub fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { - std.log.info("Getting contribution years...", .{}); - const response, const status = try client.graphql( - \\query { - \\ viewer { - \\ contributionsCollection { - \\ contributionYears - \\ } - \\ } - \\} - , null); - if (status != .ok) { - std.log.err( - "Failed to get contribution years ({?s})", - .{status.phrase()}, - ); - return error.RequestFailed; - } - const parsed = try std.json.parseFromSliceLeaky( - struct { - data: struct { - viewer: struct { - contributionsCollection: struct { - contributionYears: []u32, - }, - }, - }, - }, - alloc, - response, - .{ .ignore_unknown_fields = true }, - ); - return parsed - .data - .viewer - .contributionsCollection - .contributionYears; - } -}; - -fn repo_list( - arena: *std.heap.ArenaAllocator, - client: *HttpClient, -) !struct { u32, []Repository } { - var contributions: u32 = 0; - var repositories: std.ArrayList(Repository) = - try .initCapacity(allocator, 32); - errdefer { - for (repositories.items) |repo| { - repo.deinit(); - } - repositories.deinit(allocator); - } - var seen: std.StringHashMap(bool) = .init(arena.allocator()); - defer seen.deinit(); - - for (try Statistics.years(client, arena.allocator())) |year| { - std.log.info("Getting data from {d}...", .{year}); - var response, var status = try client.graphql( - \\query ($from: DateTime, $to: DateTime) { - \\ viewer { - \\ contributionsCollection(from: $from, to: $to) { - \\ totalRepositoryContributions - \\ totalIssueContributions - \\ totalCommitContributions - \\ totalPullRequestContributions - \\ totalPullRequestReviewContributions - \\ commitContributionsByRepository(maxRepositories: 100) { - \\ repository { - \\ nameWithOwner - \\ stargazerCount - \\ forkCount - \\ languages( - \\ first: 100, - \\ orderBy: { direction: DESC, field: SIZE } - \\ ) { - \\ edges { - \\ size - \\ node { - \\ name - \\ color - \\ } - \\ } - \\ } - \\ } - \\ } - \\ } - \\ } - \\} - , - // NOTE: Replace with actual JSON serialization if using more - // complex tyeps. This is fine as long as we're only using numbers. - try std.fmt.allocPrint( - arena.allocator(), - \\{{ - \\ "from": "{d}-01-01T00:00:00Z", - \\ "to": "{d}-01-01T00:00:00Z" - \\}} - , - .{ year, year + 1 }, - ), - ); - if (status != .ok) { - std.log.err( - "Failed to get data from {d} ({?s})", - .{ year, status.phrase() }, - ); - return error.RequestFailed; - } - const stats = (try std.json.parseFromSliceLeaky( - struct { data: struct { viewer: struct { - contributionsCollection: struct { - totalRepositoryContributions: u32, - totalIssueContributions: u32, - totalCommitContributions: u32, - totalPullRequestContributions: u32, - totalPullRequestReviewContributions: u32, - commitContributionsByRepository: []struct { - repository: struct { - nameWithOwner: []const u8, - stargazerCount: u32, - forkCount: u32, - languages: ?struct { - edges: ?[]struct { - size: u32, - node: struct { - name: []const u8, - color: ?[]const u8, - }, - }, - }, - }, - }, - }, - } } }, - arena.allocator(), - response, - .{ .ignore_unknown_fields = true }, - )).data.viewer.contributionsCollection; - std.log.info( - "Parsed {d} total repositories from {d}", - .{ stats.commitContributionsByRepository.len, year }, - ); - - contributions += stats.totalRepositoryContributions; - contributions += stats.totalIssueContributions; - contributions += stats.totalCommitContributions; - contributions += stats.totalPullRequestContributions; - contributions += stats.totalPullRequestReviewContributions; - - // TODO: if there are 100 ore more repositories, we should subdivide - // the date range in half - - for (stats.commitContributionsByRepository) |x| { - const raw_repo = x.repository; - if (seen.get(raw_repo.nameWithOwner) orelse false) { - std.log.info( - "Skipping view count for {s} (seen)", - .{raw_repo.nameWithOwner}, - ); - continue; - } - var repository = Repository{ - .name = try allocator.dupe(u8, raw_repo.nameWithOwner), - .stars = raw_repo.stargazerCount, - .forks = raw_repo.forkCount, - .languages = null, - .views = 0, - .lines_changed = 0, - }; - if (raw_repo.languages) |repo_languages| { - if (repo_languages.edges) |raw_languages| { - repository.languages = try allocator.alloc( - Language, - raw_languages.len, - ); - for ( - raw_languages, - repository.languages.?, - ) |raw, *language| { - language.* = .{ - .name = try allocator.dupe(u8, raw.node.name), - .size = raw.size, - .color = "", - }; - if (raw.node.color) |color| { - language.color = try allocator.dupe(u8, color); - } - } - } - } - std.log.info( - "Getting views for {s}...", - .{raw_repo.nameWithOwner}, - ); - response, status = try client.rest( - try std.mem.concat( - arena.allocator(), - u8, - &.{ - "https://api.github.com/repos/", - raw_repo.nameWithOwner, - "/traffic/views", - }, - ), - ); - if (status == .ok) { - repository.views = (try std.json.parseFromSliceLeaky( - struct { count: u32 }, - arena.allocator(), - response, - .{ .ignore_unknown_fields = true }, - )).count; - } else { - std.log.info( - "Failed to get views for {s} ({?s})", - .{ raw_repo.nameWithOwner, status.phrase() }, - ); - } - try repositories.append(allocator, repository); - try seen.put(raw_repo.nameWithOwner, true); - } - } - - const list = try repositories.toOwnedSlice(allocator); - std.sort.pdq(Repository, list, {}, struct { - pub fn lessThanFn(_: void, lhs: Repository, rhs: Repository) bool { - if (rhs.views == lhs.views) { - return rhs.stars + rhs.forks < lhs.stars + lhs.forks; - } - return rhs.views < lhs.views; - } - }.lessThanFn); - - return .{ contributions, list }; -} - -fn get_repos(client: *HttpClient) !Statistics { - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - - var result: Statistics = .{ .repositories = undefined }; - result.contributions, result.repositories = try repo_list(&arena, client); - errdefer result.deinit(); - - const T = struct { - repo: *Repository, - delay: i64, - timestamp: i64, - }; - var q: std.PriorityQueue(T, void, struct { - pub fn compareFn(_: void, lhs: T, rhs: T) std.math.Order { - return std.math.order(lhs.timestamp, rhs.timestamp); - } - }.compareFn) = .init(arena.allocator(), {}); - defer q.deinit(); - for (result.repositories) |*repo| { - try q.add(.{ - .repo = repo, - .delay = 2, - .timestamp = std.time.timestamp(), - }); - } - while (q.count() > 0) { - var item = q.remove(); - const now = std.time.timestamp(); - if (item.timestamp > now) { - const delay: u64 = @intCast(item.timestamp - now); - std.log.debug("Sleeping for {d}s. Waiting for {d} repos.", .{ - delay, - q.count() + 1, - }); - std.Thread.sleep(delay * std.time.ns_per_s); - } - std.log.info( - "Trying to get lines of code changed for {s}...", - .{item.repo.name}, - ); - const response, const status = try client.rest( - try std.mem.concat( - arena.allocator(), - u8, - &.{ - "https://api.github.com/repos/", - item.repo.name, - "/stats/contributors", - }, - ), - ); - switch (status) { - .ok => { - const authors = (try std.json.parseFromSliceLeaky( - []struct { - author: struct { login: []const u8 }, - weeks: []struct { - a: u32, - d: u32, - }, - }, - arena.allocator(), - response, - .{ .ignore_unknown_fields = true }, - )); - for (authors) |o| { - if (!std.mem.eql(u8, o.author.login, user)) { - continue; - } - for (o.weeks) |week| { - item.repo.lines_changed += week.a; - item.repo.lines_changed += week.d; - } - } - std.log.info( - "Got {d} lines changed by {s} in {s}", - .{ item.repo.lines_changed, user, item.repo.name }, - ); - }, - .accepted => { - item.timestamp = std.time.timestamp() + item.delay; - const old_delay: f64 = @floatFromInt(item.delay); - item.delay = @intFromFloat(old_delay * 1.5); - try q.add(item); - }, - else => { - std.log.err( - "Failed to get contribution data for {s} ({?s})", - .{ item.repo.name, status.phrase() }, - ); - return error.RequestFailed; - }, - } - } - - return result; -} - pub fn main() !void { var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; defer _ = gpa.deinit(); @@ -418,7 +40,7 @@ pub fn main() !void { ); user = ""; defer client.deinit(); - const stats = try get_repos(&client); + const stats = try Statistics.init(&client, user, allocator); defer stats.deinit(); print(stats); diff --git a/src/statistics.zig b/src/statistics.zig new file mode 100644 index 00000000000..083b027d532 --- /dev/null +++ b/src/statistics.zig @@ -0,0 +1,385 @@ +const std = @import("std"); +const HttpClient = @import("http_client.zig"); + +repositories: []Repository, +contributions: u32 = 0, + +var allocator: std.mem.Allocator = undefined; +const Statistics = @This(); + +const Language = struct { + name: []const u8, + size: u32, + color: []const u8, + + pub fn deinit(self: @This()) void { + allocator.free(self.name); + allocator.free(self.color); + } +}; + +const Repository = struct { + name: []const u8, + stars: u32, + forks: u32, + languages: ?[]Language, + views: u32, + lines_changed: u32, + + pub fn deinit(self: @This()) void { + allocator.free(self.name); + if (self.languages) |languages| { + for (languages) |language| { + language.deinit(); + } + allocator.free(languages); + } + } +}; + +pub fn deinit(self: Statistics) void { + for (self.repositories) |repository| { + repository.deinit(); + } + allocator.free(self.repositories); +} + +fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { + std.log.info("Getting contribution years...", .{}); + const response, const status = try client.graphql( + \\query { + \\ viewer { + \\ contributionsCollection { + \\ contributionYears + \\ } + \\ } + \\} + , null); + if (status != .ok) { + std.log.err( + "Failed to get contribution years ({?s})", + .{status.phrase()}, + ); + return error.RequestFailed; + } + const parsed = try std.json.parseFromSliceLeaky( + struct { + data: struct { + viewer: struct { + contributionsCollection: struct { + contributionYears: []u32, + }, + }, + }, + }, + alloc, + response, + .{ .ignore_unknown_fields = true }, + ); + return parsed + .data + .viewer + .contributionsCollection + .contributionYears; +} + +fn repo_list( + arena: *std.heap.ArenaAllocator, + client: *HttpClient, +) !struct { u32, []Repository } { + var contributions: u32 = 0; + var repositories: std.ArrayList(Repository) = + try .initCapacity(allocator, 32); + errdefer { + for (repositories.items) |repo| { + repo.deinit(); + } + repositories.deinit(allocator); + } + var seen: std.StringHashMap(bool) = .init(arena.allocator()); + defer seen.deinit(); + + for (try Statistics.years(client, arena.allocator())) |year| { + std.log.info("Getting data from {d}...", .{year}); + var response, var status = try client.graphql( + \\query ($from: DateTime, $to: DateTime) { + \\ viewer { + \\ contributionsCollection(from: $from, to: $to) { + \\ totalRepositoryContributions + \\ totalIssueContributions + \\ totalCommitContributions + \\ totalPullRequestContributions + \\ totalPullRequestReviewContributions + \\ commitContributionsByRepository(maxRepositories: 100) { + \\ repository { + \\ nameWithOwner + \\ stargazerCount + \\ forkCount + \\ languages( + \\ first: 100, + \\ orderBy: { direction: DESC, field: SIZE } + \\ ) { + \\ edges { + \\ size + \\ node { + \\ name + \\ color + \\ } + \\ } + \\ } + \\ } + \\ } + \\ } + \\ } + \\} + , + // NOTE: Replace with actual JSON serialization if using more + // complex tyeps. This is fine as long as we're only using numbers. + try std.fmt.allocPrint( + arena.allocator(), + \\{{ + \\ "from": "{d}-01-01T00:00:00Z", + \\ "to": "{d}-01-01T00:00:00Z" + \\}} + , + .{ year, year + 1 }, + ), + ); + if (status != .ok) { + std.log.err( + "Failed to get data from {d} ({?s})", + .{ year, status.phrase() }, + ); + return error.RequestFailed; + } + const stats = (try std.json.parseFromSliceLeaky( + struct { data: struct { viewer: struct { + contributionsCollection: struct { + totalRepositoryContributions: u32, + totalIssueContributions: u32, + totalCommitContributions: u32, + totalPullRequestContributions: u32, + totalPullRequestReviewContributions: u32, + commitContributionsByRepository: []struct { + repository: struct { + nameWithOwner: []const u8, + stargazerCount: u32, + forkCount: u32, + languages: ?struct { + edges: ?[]struct { + size: u32, + node: struct { + name: []const u8, + color: ?[]const u8, + }, + }, + }, + }, + }, + }, + } } }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + )).data.viewer.contributionsCollection; + std.log.info( + "Parsed {d} total repositories from {d}", + .{ stats.commitContributionsByRepository.len, year }, + ); + + contributions += stats.totalRepositoryContributions; + contributions += stats.totalIssueContributions; + contributions += stats.totalCommitContributions; + contributions += stats.totalPullRequestContributions; + contributions += stats.totalPullRequestReviewContributions; + + // TODO: if there are 100 ore more repositories, we should subdivide + // the date range in half + + for (stats.commitContributionsByRepository) |x| { + const raw_repo = x.repository; + if (seen.get(raw_repo.nameWithOwner) orelse false) { + std.log.info( + "Skipping view count for {s} (seen)", + .{raw_repo.nameWithOwner}, + ); + continue; + } + var repository = Repository{ + .name = try allocator.dupe(u8, raw_repo.nameWithOwner), + .stars = raw_repo.stargazerCount, + .forks = raw_repo.forkCount, + .languages = null, + .views = 0, + .lines_changed = 0, + }; + if (raw_repo.languages) |repo_languages| { + if (repo_languages.edges) |raw_languages| { + repository.languages = try allocator.alloc( + Language, + raw_languages.len, + ); + for ( + raw_languages, + repository.languages.?, + ) |raw, *language| { + language.* = .{ + .name = try allocator.dupe(u8, raw.node.name), + .size = raw.size, + .color = "", + }; + if (raw.node.color) |color| { + language.color = try allocator.dupe(u8, color); + } + } + } + } + std.log.info( + "Getting views for {s}...", + .{raw_repo.nameWithOwner}, + ); + response, status = try client.rest( + try std.mem.concat( + arena.allocator(), + u8, + &.{ + "https://api.github.com/repos/", + raw_repo.nameWithOwner, + "/traffic/views", + }, + ), + ); + if (status == .ok) { + repository.views = (try std.json.parseFromSliceLeaky( + struct { count: u32 }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + )).count; + } else { + std.log.info( + "Failed to get views for {s} ({?s})", + .{ raw_repo.nameWithOwner, status.phrase() }, + ); + } + try repositories.append(allocator, repository); + try seen.put(raw_repo.nameWithOwner, true); + } + } + + const list = try repositories.toOwnedSlice(allocator); + std.sort.pdq(Repository, list, {}, struct { + pub fn lessThanFn(_: void, lhs: Repository, rhs: Repository) bool { + if (rhs.views == lhs.views) { + return rhs.stars + rhs.forks < lhs.stars + lhs.forks; + } + return rhs.views < lhs.views; + } + }.lessThanFn); + + return .{ contributions, list }; +} + +pub fn init( + client: *HttpClient, + user: []const u8, + alloc: std.mem.Allocator, +) !Statistics { + allocator = alloc; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var result: Statistics = .{ .repositories = undefined }; + result.contributions, result.repositories = try repo_list(&arena, client); + errdefer result.deinit(); + + const T = struct { + repo: *Repository, + delay: i64, + timestamp: i64, + }; + var q: std.PriorityQueue(T, void, struct { + pub fn compareFn(_: void, lhs: T, rhs: T) std.math.Order { + return std.math.order(lhs.timestamp, rhs.timestamp); + } + }.compareFn) = .init(arena.allocator(), {}); + defer q.deinit(); + for (result.repositories) |*repo| { + try q.add(.{ + .repo = repo, + .delay = 2, + .timestamp = std.time.timestamp(), + }); + } + while (q.count() > 0) { + var item = q.remove(); + const now = std.time.timestamp(); + if (item.timestamp > now) { + const delay: u64 = @intCast(item.timestamp - now); + std.log.debug("Sleeping for {d}s. Waiting for {d} repos.", .{ + delay, + q.count() + 1, + }); + std.Thread.sleep(delay * std.time.ns_per_s); + } + std.log.info( + "Trying to get lines of code changed for {s}...", + .{item.repo.name}, + ); + const response, const status = try client.rest( + try std.mem.concat( + arena.allocator(), + u8, + &.{ + "https://api.github.com/repos/", + item.repo.name, + "/stats/contributors", + }, + ), + ); + switch (status) { + .ok => { + const authors = (try std.json.parseFromSliceLeaky( + []struct { + author: struct { login: []const u8 }, + weeks: []struct { + a: u32, + d: u32, + }, + }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + )); + for (authors) |o| { + if (!std.mem.eql(u8, o.author.login, user)) { + continue; + } + for (o.weeks) |week| { + item.repo.lines_changed += week.a; + item.repo.lines_changed += week.d; + } + } + std.log.info( + "Got {d} lines changed by {s} in {s}", + .{ item.repo.lines_changed, user, item.repo.name }, + ); + }, + .accepted => { + item.timestamp = std.time.timestamp() + item.delay; + const old_delay: f64 = @floatFromInt(item.delay); + item.delay = @intFromFloat(old_delay * 1.5); + try q.add(item); + }, + else => { + std.log.err( + "Failed to get contribution data for {s} ({?s})", + .{ item.repo.name, status.phrase() }, + ); + return error.RequestFailed; + }, + } + } + + return result; +} From ce45bf2c0de01c26579073c2acb42628125ca273 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Feb 2026 20:23:19 -0500 Subject: [PATCH 19/72] Use probabilistic exponential backoff with jitter --- src/statistics.zig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/statistics.zig b/src/statistics.zig index 083b027d532..ed973cb44b6 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -367,8 +367,12 @@ pub fn init( }, .accepted => { item.timestamp = std.time.timestamp() + item.delay; - const old_delay: f64 = @floatFromInt(item.delay); - item.delay = @intFromFloat(old_delay * 1.5); + // Exponential backoff (in expectation) with jitter + item.delay += std.crypto.random.intRangeAtMost( + i64, + 1, + item.delay, + ); try q.add(item); }, else => { From 4c2a3a324e106bb3c432652c1063ab6101705261 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 25 Feb 2026 00:52:17 -0500 Subject: [PATCH 20/72] Fix keep alive failure retry logic once and for all --- src/http_client.zig | 49 +++++++++++++++++++++++++++++++-------------- src/statistics.zig | 6 +++--- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/http_client.zig b/src/http_client.zig index 4d1df4dbf31..d3ea8c4860d 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -12,7 +12,6 @@ last_request: ?i64 = null, const Self = @This(); const Response = struct { []const u8, std.http.Status }; -const KEEP_ALIVE_TIMEOUT: i64 = 16; pub fn init(allocator: std.mem.Allocator, token: []const u8) !Self { const arena = try allocator.create(std.heap.ArenaAllocator); @@ -44,17 +43,27 @@ pub fn get( ); defer writer.deinit(); const now = std.time.timestamp(); - const status = (try self.client.fetch(.{ + const status = (try (self.client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.writer, .headers = headers, .extra_headers = extra_headers, - // Work around failures from keep alive connections closing after - // timeout and not being automatically reopened by Zig - .keep_alive = if (self.last_request) |last| - now - last > KEEP_ALIVE_TIMEOUT - else - true, + }) catch |err| switch (err) { + error.HttpConnectionClosing => { + // Handle a Zig HTTP bug where keep-alive connections are closed by + // the server after a timeout, but the client doesn't handle it + // properly. For now we nuke the whole client (and associate + // connection pool) and make a new one, but there might be a better + // way to handle this. + std.log.debug( + "Keep alive connection closed. Initializing a new client.", + .{}, + ); + self.client.deinit(); + self.client = .{ .allocator = self.arena.allocator() }; + return self.get(url, headers, extra_headers); + }, + else => err, })).status; self.last_request = now; return .{ try writer.toOwnedSlice(), status }; @@ -72,17 +81,27 @@ pub fn post( ); defer writer.deinit(); const now = std.time.timestamp(); - const status = (try self.client.fetch(.{ + const status = (try (self.client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.writer, .payload = body, .headers = headers, - // Work around failures from keep alive connections closing after - // timeout and not being automatically reopened by Zig - .keep_alive = if (self.last_request) |last| - now - last > KEEP_ALIVE_TIMEOUT - else - true, + }) catch |err| switch (err) { + error.HttpConnectionClosing => { + // Handle a Zig HTTP bug where keep-alive connections are closed by + // the server after a timeout, but the client doesn't handle it + // properly. For now we nuke the whole client (and associate + // connection pool) and make a new one, but there might be a better + // way to handle this. + std.log.debug( + "Keep alive connection closed. Initializing a new client.", + .{}, + ); + self.client.deinit(); + self.client = .{ .allocator = self.arena.allocator() }; + return self.post(url, body, headers); + }, + else => err, })).status; self.last_request = now; return .{ try writer.toOwnedSlice(), status }; diff --git a/src/statistics.zig b/src/statistics.zig index ed973cb44b6..ba5395ea627 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -199,7 +199,7 @@ fn repo_list( for (stats.commitContributionsByRepository) |x| { const raw_repo = x.repository; if (seen.get(raw_repo.nameWithOwner) orelse false) { - std.log.info( + std.log.debug( "Skipping view count for {s} (seen)", .{raw_repo.nameWithOwner}, ); @@ -307,7 +307,7 @@ pub fn init( for (result.repositories) |*repo| { try q.add(.{ .repo = repo, - .delay = 2, + .delay = 8, .timestamp = std.time.timestamp(), }); } @@ -370,7 +370,7 @@ pub fn init( // Exponential backoff (in expectation) with jitter item.delay += std.crypto.random.intRangeAtMost( i64, - 1, + 2, item.delay, ); try q.add(item); From adf437ddbd0053d7743750582a9040b9d55d3a75 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 25 Feb 2026 10:47:02 -0500 Subject: [PATCH 21/72] Refactor lines changed into separate function --- src/main.zig | 3 +- src/statistics.zig | 79 +++++++++++++++++++++++++++------------------- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/src/main.zig b/src/main.zig index 243b78ceb8b..adeae4327b9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -38,9 +38,8 @@ pub fn main() !void { allocator, "", ); - user = ""; defer client.deinit(); - const stats = try Statistics.init(&client, user, allocator); + const stats = try Statistics.init(&client, allocator); defer stats.deinit(); print(stats); diff --git a/src/statistics.zig b/src/statistics.zig index ba5395ea627..e9abc73068a 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -2,6 +2,7 @@ const std = @import("std"); const HttpClient = @import("http_client.zig"); repositories: []Repository, +user: []const u8, contributions: u32 = 0, var allocator: std.mem.Allocator = undefined; @@ -37,11 +38,23 @@ const Repository = struct { } }; +pub fn init(client: *HttpClient, a: std.mem.Allocator) !Statistics { + allocator = a; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var self: Statistics = try get_repos(&arena, client); + errdefer self.deinit(); + try self.get_lines_changed(&arena, client); + return self; +} + pub fn deinit(self: Statistics) void { for (self.repositories) |repository| { repository.deinit(); } allocator.free(self.repositories); + allocator.free(self.user); } fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { @@ -63,15 +76,11 @@ fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { return error.RequestFailed; } const parsed = try std.json.parseFromSliceLeaky( - struct { - data: struct { - viewer: struct { - contributionsCollection: struct { - contributionYears: []u32, - }, - }, + struct { data: struct { viewer: struct { + contributionsCollection: struct { + contributionYears: []u32, }, - }, + } } }, alloc, response, .{ .ignore_unknown_fields = true }, @@ -83,11 +92,12 @@ fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { .contributionYears; } -fn repo_list( +fn get_repos( arena: *std.heap.ArenaAllocator, client: *HttpClient, -) !struct { u32, []Repository } { +) !Statistics { var contributions: u32 = 0; + var user: []const u8 = undefined; var repositories: std.ArrayList(Repository) = try .initCapacity(allocator, 32); errdefer { @@ -104,6 +114,7 @@ fn repo_list( var response, var status = try client.graphql( \\query ($from: DateTime, $to: DateTime) { \\ viewer { + \\ login \\ contributionsCollection(from: $from, to: $to) { \\ totalRepositoryContributions \\ totalIssueContributions @@ -152,8 +163,9 @@ fn repo_list( ); return error.RequestFailed; } - const stats = (try std.json.parseFromSliceLeaky( + const viewer = (try std.json.parseFromSliceLeaky( struct { data: struct { viewer: struct { + login: []const u8, contributionsCollection: struct { totalRepositoryContributions: u32, totalIssueContributions: u32, @@ -181,7 +193,9 @@ fn repo_list( arena.allocator(), response, .{ .ignore_unknown_fields = true }, - )).data.viewer.contributionsCollection; + )).data.viewer; + user = viewer.login; + const stats = viewer.contributionsCollection; std.log.info( "Parsed {d} total repositories from {d}", .{ stats.commitContributionsByRepository.len, year }, @@ -226,6 +240,7 @@ fn repo_list( language.* = .{ .name = try allocator.dupe(u8, raw.node.name), .size = raw.size, + // TODO: Add sensible default color .color = "", }; if (raw.node.color) |color| { @@ -277,22 +292,18 @@ fn repo_list( } }.lessThanFn); - return .{ contributions, list }; + return .{ + .contributions = contributions, + .user = try allocator.dupe(u8, user), + .repositories = list, + }; } -pub fn init( +fn get_lines_changed( + self: *Statistics, + arena: *std.heap.ArenaAllocator, client: *HttpClient, - user: []const u8, - alloc: std.mem.Allocator, -) !Statistics { - allocator = alloc; - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - - var result: Statistics = .{ .repositories = undefined }; - result.contributions, result.repositories = try repo_list(&arena, client); - errdefer result.deinit(); - +) !void { const T = struct { repo: *Repository, delay: i64, @@ -304,7 +315,7 @@ pub fn init( } }.compareFn) = .init(arena.allocator(), {}); defer q.deinit(); - for (result.repositories) |*repo| { + for (self.repositories) |*repo| { try q.add(.{ .repo = repo, .delay = 8, @@ -316,9 +327,10 @@ pub fn init( const now = std.time.timestamp(); if (item.timestamp > now) { const delay: u64 = @intCast(item.timestamp - now); - std.log.debug("Sleeping for {d}s. Waiting for {d} repos.", .{ + std.log.debug("Sleeping for {d}s. Waiting for {d} repo{s}.", .{ delay, q.count() + 1, + if (q.count() != 0) "s" else "", }); std.Thread.sleep(delay * std.time.ns_per_s); } @@ -352,7 +364,7 @@ pub fn init( .{ .ignore_unknown_fields = true }, )); for (authors) |o| { - if (!std.mem.eql(u8, o.author.login, user)) { + if (!std.mem.eql(u8, o.author.login, self.user)) { continue; } for (o.weeks) |week| { @@ -361,8 +373,13 @@ pub fn init( } } std.log.info( - "Got {d} lines changed by {s} in {s}", - .{ item.repo.lines_changed, user, item.repo.name }, + "Got {d} line{s} changed by {s} in {s}", + .{ + item.repo.lines_changed, + if (item.repo.lines_changed != 1) "s" else "", + self.user, + item.repo.name, + }, ); }, .accepted => { @@ -384,6 +401,4 @@ pub fn init( }, } } - - return result; } From f5bbf6a160b8e945f912f2b6231f410c1d22e4a6 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 25 Feb 2026 17:15:06 -0500 Subject: [PATCH 22/72] Get contribution lines early for faster runtime --- src/statistics.zig | 126 +++++++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 55 deletions(-) diff --git a/src/statistics.zig b/src/statistics.zig index e9abc73068a..8902ca29aee 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -36,6 +36,62 @@ const Repository = struct { allocator.free(languages); } } + + pub fn get_lines_changed( + self: *@This(), + arena: *std.heap.ArenaAllocator, + client: *HttpClient, + user: []const u8, + ) !std.http.Status { + std.log.debug( + "Trying to get lines of code changed for {s}...", + .{self.name}, + ); + const response, const status = try client.rest( + try std.mem.concat( + arena.allocator(), + u8, + &.{ + "https://api.github.com/repos/", + self.name, + "/stats/contributors", + }, + ), + ); + if (status == .ok) { + const authors = (try std.json.parseFromSliceLeaky( + []struct { + author: struct { login: []const u8 }, + weeks: []struct { + a: u32, + d: u32, + }, + }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + )); + for (authors) |o| { + if (!std.mem.eql(u8, o.author.login, user)) { + continue; + } + for (o.weeks) |week| { + self.lines_changed += week.a; + self.lines_changed += week.d; + } + } + std.log.info( + "Got {d} line{s} changed by {s} in {s}", + .{ + self.lines_changed, + if (self.lines_changed != 1) "s" else "", + user, + self.name, + }, + ); + } + return status; + } }; pub fn init(client: *HttpClient, a: std.mem.Allocator) !Statistics { @@ -214,7 +270,7 @@ fn get_repos( const raw_repo = x.repository; if (seen.get(raw_repo.nameWithOwner) orelse false) { std.log.debug( - "Skipping view count for {s} (seen)", + "Skipping {s} (seen)", .{raw_repo.nameWithOwner}, ); continue; @@ -227,6 +283,7 @@ fn get_repos( .views = 0, .lines_changed = 0, }; + if (raw_repo.languages) |repo_languages| { if (repo_languages.edges) |raw_languages| { repository.languages = try allocator.alloc( @@ -249,6 +306,8 @@ fn get_repos( } } } + errdefer repository.deinit(); + std.log.info( "Getting views for {s}...", .{raw_repo.nameWithOwner}, @@ -277,6 +336,9 @@ fn get_repos( .{ raw_repo.nameWithOwner, status.phrase() }, ); } + + _ = try repository.get_lines_changed(arena, client, user); + try repositories.append(allocator, repository); try seen.put(raw_repo.nameWithOwner, true); } @@ -316,6 +378,9 @@ fn get_lines_changed( }.compareFn) = .init(arena.allocator(), {}); defer q.deinit(); for (self.repositories) |*repo| { + if (repo.lines_changed > 0) { + continue; + } try q.add(.{ .repo = repo, .delay = 8, @@ -334,65 +399,16 @@ fn get_lines_changed( }); std.Thread.sleep(delay * std.time.ns_per_s); } - std.log.info( - "Trying to get lines of code changed for {s}...", - .{item.repo.name}, - ); - const response, const status = try client.rest( - try std.mem.concat( - arena.allocator(), - u8, - &.{ - "https://api.github.com/repos/", - item.repo.name, - "/stats/contributors", - }, - ), - ); - switch (status) { - .ok => { - const authors = (try std.json.parseFromSliceLeaky( - []struct { - author: struct { login: []const u8 }, - weeks: []struct { - a: u32, - d: u32, - }, - }, - arena.allocator(), - response, - .{ .ignore_unknown_fields = true }, - )); - for (authors) |o| { - if (!std.mem.eql(u8, o.author.login, self.user)) { - continue; - } - for (o.weeks) |week| { - item.repo.lines_changed += week.a; - item.repo.lines_changed += week.d; - } - } - std.log.info( - "Got {d} line{s} changed by {s} in {s}", - .{ - item.repo.lines_changed, - if (item.repo.lines_changed != 1) "s" else "", - self.user, - item.repo.name, - }, - ); - }, + switch (try item.repo.get_lines_changed(arena, client, self.user)) { + .ok => {}, .accepted => { item.timestamp = std.time.timestamp() + item.delay; // Exponential backoff (in expectation) with jitter - item.delay += std.crypto.random.intRangeAtMost( - i64, - 2, - item.delay, - ); + item.delay += + std.crypto.random.intRangeAtMost(i64, 2, item.delay); try q.add(item); }, - else => { + else => |status| { std.log.err( "Failed to get contribution data for {s} ({?s})", .{ item.repo.name, status.phrase() }, From 871a2f8f0ceb704bea982262b3c29d640c06a908 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Fri, 27 Feb 2026 12:45:23 -0500 Subject: [PATCH 23/72] Get username only once --- src/statistics.zig | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/statistics.zig b/src/statistics.zig index 8902ca29aee..a42817a1396 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -113,11 +113,15 @@ pub fn deinit(self: Statistics) void { allocator.free(self.user); } -fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { +fn get_years( + client: *HttpClient, + alloc: std.mem.Allocator, +) !struct { []u32, []const u8 } { std.log.info("Getting contribution years...", .{}); const response, const status = try client.graphql( \\query { \\ viewer { + \\ login \\ contributionsCollection { \\ contributionYears \\ } @@ -131,8 +135,9 @@ fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { ); return error.RequestFailed; } - const parsed = try std.json.parseFromSliceLeaky( + const parsed = (try std.json.parseFromSliceLeaky( struct { data: struct { viewer: struct { + login: []const u8, contributionsCollection: struct { contributionYears: []u32, }, @@ -140,12 +145,8 @@ fn years(client: *HttpClient, alloc: std.mem.Allocator) ![]u32 { alloc, response, .{ .ignore_unknown_fields = true }, - ); - return parsed - .data - .viewer - .contributionsCollection - .contributionYears; + )).data.viewer; + return .{ parsed.contributionsCollection.contributionYears, parsed.login }; } fn get_repos( @@ -153,7 +154,6 @@ fn get_repos( client: *HttpClient, ) !Statistics { var contributions: u32 = 0; - var user: []const u8 = undefined; var repositories: std.ArrayList(Repository) = try .initCapacity(allocator, 32); errdefer { @@ -165,12 +165,13 @@ fn get_repos( var seen: std.StringHashMap(bool) = .init(arena.allocator()); defer seen.deinit(); - for (try Statistics.years(client, arena.allocator())) |year| { + const years, const user = try get_years(client, arena.allocator()); + std.log.info("Getting data for user {s}...", .{user}); + for (years) |year| { std.log.info("Getting data from {d}...", .{year}); var response, var status = try client.graphql( \\query ($from: DateTime, $to: DateTime) { \\ viewer { - \\ login \\ contributionsCollection(from: $from, to: $to) { \\ totalRepositoryContributions \\ totalIssueContributions @@ -221,7 +222,6 @@ fn get_repos( } const viewer = (try std.json.parseFromSliceLeaky( struct { data: struct { viewer: struct { - login: []const u8, contributionsCollection: struct { totalRepositoryContributions: u32, totalIssueContributions: u32, @@ -250,7 +250,7 @@ fn get_repos( response, .{ .ignore_unknown_fields = true }, )).data.viewer; - user = viewer.login; + const stats = viewer.contributionsCollection; std.log.info( "Parsed {d} total repositories from {d}", From 729d4215a227fd4d4d5cccab77f166ee6ae3fe1c Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Fri, 27 Feb 2026 12:57:14 -0500 Subject: [PATCH 24/72] Add very basic arg and env parsing --- src/argparse.zig | 95 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 18 ++++++--- 2 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 src/argparse.zig diff --git a/src/argparse.zig b/src/argparse.zig new file mode 100644 index 00000000000..29bb00dd931 --- /dev/null +++ b/src/argparse.zig @@ -0,0 +1,95 @@ +const std = @import("std"); + +pub fn parse(allocator: std.mem.Allocator, T: type) !T { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const fields = @typeInfo(T).@"struct".fields; + var seen = [_]bool{false} ** fields.len; + var result: T = undefined; + // TODO: An error when some of the fields are set but not others will + // leave dangling pointers + + inline for (fields, &seen) |field, *seen_field| { + if (field.defaultValue()) |default| { + @field(result, field.name) = default; + seen_field.* = true; + } + } + + { + var env = try std.process.getEnvMap(a); + defer env.deinit(); + var iterator = env.iterator(); + while (iterator.next()) |entry| { + const key = try a.dupe(u8, entry.key_ptr.*); + defer a.free(key); + std.mem.replaceScalar(u8, key, '-', '_'); + inline for (fields, &seen) |field, *seen_field| { + if (std.ascii.eqlIgnoreCase(key, field.name)) { + // TODO: Switch on field type and parse if applicable + @field(result, field.name) = try allocator.dupe( + u8, + entry.value_ptr.*, + ); + seen_field.* = true; + } + } + } + } + + { + const args = try std.process.argsAlloc(a); + defer std.process.argsFree(a, args); + var j: usize = 1; + args: while (j < args.len) : (j += 1) { + const raw_arg = args[j]; + if (std.mem.eql(u8, raw_arg, "-h") or + std.mem.eql(u8, raw_arg, "--help")) + { + printUsage(T, args[0]); + std.process.exit(0); + } + // TODO: Handle one-letter arguments + if (!std.mem.startsWith(u8, raw_arg, "--")) { + // TODO: Use actual printing + std.debug.print("Unknown argument: '{s}'\n", .{raw_arg}); + printUsage(T, args[0]); + std.process.exit(1); + } + const arg = try a.dupe(u8, raw_arg[2..]); + defer a.free(arg); + std.mem.replaceScalar(u8, arg, '-', '_'); + inline for (fields, &seen) |field, *seen_field| { + if (std.ascii.eqlIgnoreCase(arg, field.name)) { + // TODO: Switch on field type and parse if applicable + j += 1; + // TODO: Fix possible memory leak + @field(result, field.name) = try allocator.dupe(u8, args[j]); + seen_field.* = true; + continue :args; + } + } + // TODO: Use actual printing + std.debug.print("Unknown argument: '{s}'\n", .{raw_arg}); + printUsage(T, args[0]); + std.process.exit(1); + } + } + + inline for (fields, seen) |field, seen_field| { + if (!seen_field) { + std.log.err("Missing required argument {s}", .{field.name}); + return error.MissingArgument; + } + } + + return result; +} + +pub fn printUsage(T: type, argv0: []const u8) void { + // TODO: Improve + std.debug.print("Usage: {s}\n", .{argv0}); + _ = T; +} diff --git a/src/main.zig b/src/main.zig index adeae4327b9..54210d0e8ea 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,6 @@ const builtin = @import("builtin"); const std = @import("std"); +const argparse = @import("argparse.zig"); const HttpClient = @import("http_client.zig"); const Statistics = @import("statistics.zig"); @@ -26,18 +27,23 @@ fn logFn( } } +const Args = struct { + api_key: []const u8, + + pub fn deinit(self: @This()) void { + allocator.free(self.api_key); + } +}; + pub fn main() !void { var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; defer _ = gpa.deinit(); allocator = gpa.allocator(); - // TODO: Parse environment variables - // TODO: Parse CLI flags + const args = try argparse.parse(allocator, Args); + defer args.deinit(); - var client: HttpClient = try .init( - allocator, - "", - ); + var client: HttpClient = try .init(allocator, args.api_key); defer client.deinit(); const stats = try Statistics.init(&client, allocator); defer stats.deinit(); From 1520a6cbb7882af5aa031a617d500832a84ffb4d Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 1 Mar 2026 22:16:07 -0500 Subject: [PATCH 25/72] Fix memory leaks --- src/argparse.zig | 89 ++++++++++++++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/src/argparse.zig b/src/argparse.zig index 29bb00dd931..a248af54a82 100644 --- a/src/argparse.zig +++ b/src/argparse.zig @@ -1,5 +1,20 @@ const std = @import("std"); +fn strip_optional(T: type) type { + const info = @typeInfo(T); + if (info != .optional) return T; + return strip_optional(info.optional.child); +} + +fn free_field(allocator: std.mem.Allocator, field: anytype) void { + switch (@typeInfo(@TypeOf(field))) { + .array => allocator.free(field), + .optional => free_field(allocator, field.?), + .bool, .int, .float, .@"enum" => {}, + else => unreachable, + } +} + pub fn parse(allocator: std.mem.Allocator, T: type) !T { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); @@ -8,33 +23,10 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { const fields = @typeInfo(T).@"struct".fields; var seen = [_]bool{false} ** fields.len; var result: T = undefined; - // TODO: An error when some of the fields are set but not others will - // leave dangling pointers - - inline for (fields, &seen) |field, *seen_field| { - if (field.defaultValue()) |default| { - @field(result, field.name) = default; - seen_field.* = true; - } - } - - { - var env = try std.process.getEnvMap(a); - defer env.deinit(); - var iterator = env.iterator(); - while (iterator.next()) |entry| { - const key = try a.dupe(u8, entry.key_ptr.*); - defer a.free(key); - std.mem.replaceScalar(u8, key, '-', '_'); - inline for (fields, &seen) |field, *seen_field| { - if (std.ascii.eqlIgnoreCase(key, field.name)) { - // TODO: Switch on field type and parse if applicable - @field(result, field.name) = try allocator.dupe( - u8, - entry.value_ptr.*, - ); - seen_field.* = true; - } + errdefer { + inline for (fields, seen) |field, seen_field| { + if (seen_field) { + free_field(allocator, @field(result, field.name)); } } } @@ -42,9 +34,9 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { { const args = try std.process.argsAlloc(a); defer std.process.argsFree(a, args); - var j: usize = 1; - args: while (j < args.len) : (j += 1) { - const raw_arg = args[j]; + var i: usize = 1; + args: while (i < args.len) : (i += 1) { + const raw_arg = args[i]; if (std.mem.eql(u8, raw_arg, "-h") or std.mem.eql(u8, raw_arg, "--help")) { @@ -62,11 +54,11 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { defer a.free(arg); std.mem.replaceScalar(u8, arg, '-', '_'); inline for (fields, &seen) |field, *seen_field| { - if (std.ascii.eqlIgnoreCase(arg, field.name)) { + if (!seen_field.* and std.ascii.eqlIgnoreCase(arg, field.name)) { // TODO: Switch on field type and parse if applicable - j += 1; + i += 1; // TODO: Fix possible memory leak - @field(result, field.name) = try allocator.dupe(u8, args[j]); + @field(result, field.name) = try allocator.dupe(u8, args[i]); seen_field.* = true; continue :args; } @@ -78,6 +70,37 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { } } + { + var env = try std.process.getEnvMap(a); + defer env.deinit(); + var iterator = env.iterator(); + while (iterator.next()) |entry| { + const key = try a.dupe(u8, entry.key_ptr.*); + defer a.free(key); + std.mem.replaceScalar(u8, key, '-', '_'); + inline for (fields, &seen) |field, *seen_field| { + if (!seen_field.* and std.ascii.eqlIgnoreCase(key, field.name)) { + // TODO: Switch on field type and parse if applicable + @field(result, field.name) = try allocator.dupe( + u8, + entry.value_ptr.*, + ); + seen_field.* = true; + } + } + } + } + + inline for (fields, &seen) |field, *seen_field| { + if (!seen_field.*) { + if (field.defaultValue()) |default| { + // TODO: Switch on field type and duplicate if applicable + @field(result, field.name) = default; + seen_field.* = true; + } + } + } + inline for (fields, seen) |field, seen_field| { if (!seen_field) { std.log.err("Missing required argument {s}", .{field.name}); From 1713e8d1bce5b7abe705c6d3c5ee84f125843639 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 1 Mar 2026 23:18:32 -0500 Subject: [PATCH 26/72] Print usage --- src/argparse.zig | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/argparse.zig b/src/argparse.zig index a248af54a82..31411d0ed36 100644 --- a/src/argparse.zig +++ b/src/argparse.zig @@ -15,8 +15,14 @@ fn free_field(allocator: std.mem.Allocator, field: anytype) void { } } +var stdout: *std.Io.Writer = undefined; +var arena: std.heap.ArenaAllocator = undefined; + pub fn parse(allocator: std.mem.Allocator, T: type) !T { - var arena = std.heap.ArenaAllocator.init(allocator); + var stdout_writer = std.fs.File.stdout().writer(&.{}); + stdout = &stdout_writer.interface; + + arena = .init(allocator); defer arena.deinit(); const a = arena.allocator(); @@ -40,14 +46,13 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { if (std.mem.eql(u8, raw_arg, "-h") or std.mem.eql(u8, raw_arg, "--help")) { - printUsage(T, args[0]); + try printUsage(T, args[0]); std.process.exit(0); } // TODO: Handle one-letter arguments if (!std.mem.startsWith(u8, raw_arg, "--")) { - // TODO: Use actual printing - std.debug.print("Unknown argument: '{s}'\n", .{raw_arg}); - printUsage(T, args[0]); + try stdout.print("Unknown argument: '{s}'\n", .{raw_arg}); + try printUsage(T, args[0]); std.process.exit(1); } const arg = try a.dupe(u8, raw_arg[2..]); @@ -63,9 +68,8 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { continue :args; } } - // TODO: Use actual printing - std.debug.print("Unknown argument: '{s}'\n", .{raw_arg}); - printUsage(T, args[0]); + try stdout.print("Unknown argument: '{s}'\n", .{raw_arg}); + try printUsage(T, args[0]); std.process.exit(1); } } @@ -111,8 +115,25 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { return result; } -pub fn printUsage(T: type, argv0: []const u8) void { - // TODO: Improve - std.debug.print("Usage: {s}\n", .{argv0}); - _ = T; +fn printUsage(T: type, argv0: []const u8) !void { + const a = arena.allocator(); + try stdout.print("Usage: {s} [options]\n\n", .{argv0}); + try stdout.print("Options:\n", .{}); + const fields = @typeInfo(T).@"struct".fields; + inline for (fields) |field| { + switch (@typeInfo(strip_optional(field.type))) { + .bool => { + const flag_version = try a.dupe(u8, field.name); + defer a.free(flag_version); + std.mem.replaceScalar(u8, flag_version, '_', '-'); + try stdout.print("--{s}\n", .{flag_version}); + }, + else => { + const flag_version = try a.dupe(u8, field.name); + defer a.free(flag_version); + std.mem.replaceScalar(u8, flag_version, '_', '-'); + try stdout.print("--{s} {s}\n", .{ flag_version, field.name }); + }, + } + } } From 68cd472d21bfa501c4907fbd201f12feb9acff13 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 1 Mar 2026 23:56:27 -0500 Subject: [PATCH 27/72] Parse different types of struct fields --- src/argparse.zig | 75 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/src/argparse.zig b/src/argparse.zig index 31411d0ed36..d06e1643c2b 100644 --- a/src/argparse.zig +++ b/src/argparse.zig @@ -8,10 +8,10 @@ fn strip_optional(T: type) type { fn free_field(allocator: std.mem.Allocator, field: anytype) void { switch (@typeInfo(@TypeOf(field))) { - .array => allocator.free(field), + .pointer => allocator.free(field), .optional => free_field(allocator, field.?), .bool, .int, .float, .@"enum" => {}, - else => unreachable, + else => @compileError("Disallowed struct field type."), } } @@ -37,9 +37,9 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { } } + const args = try std.process.argsAlloc(a); + defer std.process.argsFree(a, args); { - const args = try std.process.argsAlloc(a); - defer std.process.argsFree(a, args); var i: usize = 1; args: while (i < args.len) : (i += 1) { const raw_arg = args[i]; @@ -60,10 +60,30 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { std.mem.replaceScalar(u8, arg, '-', '_'); inline for (fields, &seen) |field, *seen_field| { if (!seen_field.* and std.ascii.eqlIgnoreCase(arg, field.name)) { - // TODO: Switch on field type and parse if applicable - i += 1; - // TODO: Fix possible memory leak - @field(result, field.name) = try allocator.dupe(u8, args[i]); + const t = @typeInfo(strip_optional(field.type)); + if (t == .bool) { + @field(result, field.name) = true; + } else { + i += 1; + if (i >= args.len) { + try stdout.print( + "Missing required value for argument {s} {s}\n", + .{ raw_arg, field.name }, + ); + try printUsage(T, args[0]); + std.process.exit(1); + } + switch (t) { + // TODO + .int, .float, .@"enum" => comptime unreachable, + .pointer => @field( + result, + field.name, + ) = try allocator.dupe(u8, args[i]), + .bool => comptime unreachable, + else => @compileError("Disallowed struct field type."), + } + } seen_field.* = true; continue :args; } @@ -84,11 +104,21 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { std.mem.replaceScalar(u8, key, '-', '_'); inline for (fields, &seen) |field, *seen_field| { if (!seen_field.* and std.ascii.eqlIgnoreCase(key, field.name)) { - // TODO: Switch on field type and parse if applicable - @field(result, field.name) = try allocator.dupe( - u8, - entry.value_ptr.*, - ); + switch (@typeInfo(strip_optional(field.type))) { + .bool => { + const value = try a.dupe(u8, entry.value_ptr.*); + defer a.free(value); + @field(result, field.name) = value.len > 0 and + !std.ascii.eqlIgnoreCase(value, "false"); + }, + // TODO + .int, .float, .@"enum" => comptime unreachable, + .pointer => @field( + result, + field.name, + ) = try allocator.dupe(u8, entry.value_ptr.*), + else => @compileError("Disallowed struct field type."), + } seen_field.* = true; } } @@ -98,8 +128,14 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { inline for (fields, &seen) |field, *seen_field| { if (!seen_field.*) { if (field.defaultValue()) |default| { - // TODO: Switch on field type and duplicate if applicable - @field(result, field.name) = default; + switch (@typeInfo(strip_optional(field.type))) { + .bool, .int, .float, .@"enum" => @field(result, field.name) = default, + .pointer => @field( + result, + field.name, + ) = try allocator.dupe(u8, default), + else => @compileError("Disallowed struct field type."), + } seen_field.* = true; } } @@ -107,8 +143,13 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { inline for (fields, seen) |field, seen_field| { if (!seen_field) { - std.log.err("Missing required argument {s}", .{field.name}); - return error.MissingArgument; + if (@typeInfo(strip_optional(field.type)) == .bool) { + @field(result, field.name) = false; + } else { + try stdout.print("Missing required argument {s}\n", .{field.name}); + try printUsage(T, args[0]); + std.process.exit(1); + } } } From 6aca7f1fed55a88406f4e706f73d8b4ad7d8b65b Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Mon, 2 Mar 2026 21:42:02 -0500 Subject: [PATCH 28/72] Fix free_field bug, split long lines --- src/argparse.zig | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/argparse.zig b/src/argparse.zig index d06e1643c2b..414c5157fe9 100644 --- a/src/argparse.zig +++ b/src/argparse.zig @@ -9,7 +9,7 @@ fn strip_optional(T: type) type { fn free_field(allocator: std.mem.Allocator, field: anytype) void { switch (@typeInfo(@TypeOf(field))) { .pointer => allocator.free(field), - .optional => free_field(allocator, field.?), + .optional => if (field) |v| free_field(allocator, v), .bool, .int, .float, .@"enum" => {}, else => @compileError("Disallowed struct field type."), } @@ -49,17 +49,21 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { try printUsage(T, args[0]); std.process.exit(0); } + // TODO: Handle one-letter arguments if (!std.mem.startsWith(u8, raw_arg, "--")) { try stdout.print("Unknown argument: '{s}'\n", .{raw_arg}); try printUsage(T, args[0]); std.process.exit(1); } + const arg = try a.dupe(u8, raw_arg[2..]); defer a.free(arg); std.mem.replaceScalar(u8, arg, '-', '_'); inline for (fields, &seen) |field, *seen_field| { - if (!seen_field.* and std.ascii.eqlIgnoreCase(arg, field.name)) { + if (!seen_field.* and + std.ascii.eqlIgnoreCase(arg, field.name)) + { const t = @typeInfo(strip_optional(field.type)); if (t == .bool) { @field(result, field.name) = true; @@ -81,13 +85,16 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { field.name, ) = try allocator.dupe(u8, args[i]), .bool => comptime unreachable, - else => @compileError("Disallowed struct field type."), + else => @compileError( + "Disallowed struct field type.", + ), } } seen_field.* = true; continue :args; } } + try stdout.print("Unknown argument: '{s}'\n", .{raw_arg}); try printUsage(T, args[0]); std.process.exit(1); @@ -103,7 +110,9 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { defer a.free(key); std.mem.replaceScalar(u8, key, '-', '_'); inline for (fields, &seen) |field, *seen_field| { - if (!seen_field.* and std.ascii.eqlIgnoreCase(key, field.name)) { + if (!seen_field.* and + std.ascii.eqlIgnoreCase(key, field.name)) + { switch (@typeInfo(strip_optional(field.type))) { .bool => { const value = try a.dupe(u8, entry.value_ptr.*); @@ -129,11 +138,13 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { if (!seen_field.*) { if (field.defaultValue()) |default| { switch (@typeInfo(strip_optional(field.type))) { - .bool, .int, .float, .@"enum" => @field(result, field.name) = default, + .bool, .int, .float, .@"enum" => { + @field(result, field.name) = default; + }, .pointer => @field( result, field.name, - ) = try allocator.dupe(u8, default), + ) = if (default) |p| try allocator.dupe(u8, p) else null, else => @compileError("Disallowed struct field type."), } seen_field.* = true; @@ -146,7 +157,10 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { if (@typeInfo(strip_optional(field.type)) == .bool) { @field(result, field.name) = false; } else { - try stdout.print("Missing required argument {s}\n", .{field.name}); + try stdout.print( + "Missing required argument {s}\n", + .{field.name}, + ); try printUsage(T, args[0]); std.process.exit(1); } From 7b1d9d25547584829e7b9f4f2f134161891900bd Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Mon, 2 Mar 2026 23:12:10 -0500 Subject: [PATCH 29/72] Print raw data to stdout or a file based on CLI --- src/main.zig | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/main.zig b/src/main.zig index 54210d0e8ea..c2b0e95adce 100644 --- a/src/main.zig +++ b/src/main.zig @@ -29,9 +29,11 @@ fn logFn( const Args = struct { api_key: []const u8, + json_output_file: ?[]const u8 = null, pub fn deinit(self: @This()) void { allocator.free(self.api_key); + if (self.json_output_file) |output| allocator.free(output); } }; @@ -47,23 +49,28 @@ pub fn main() !void { defer client.deinit(); const stats = try Statistics.init(&client, allocator); defer stats.deinit(); - print(stats); - // TODO: Output images from templates -} + if (args.json_output_file) |path| { + const out = + if (std.mem.eql(u8, path, "-")) + std.fs.File.stdout() + else + try std.fs.cwd().createFile(path, .{}); + defer out.close(); + var write_buffer: [0x100]u8 = undefined; + var _writer = out.writer(&write_buffer); + const writer = &_writer.interface; -// TODO: Remove -fn print(x: anytype) void { - if (builtin.mode != .Debug) { - @compileError("Do not use JSON print in real code!"); + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + try writer.writeAll( + try std.json.Stringify.valueAlloc( + arena.allocator(), + stats, + .{ .whitespace = .indent_2 }, + ), + ); } - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - std.debug.print("{s}\n", .{ - std.json.Stringify.valueAlloc( - arena.allocator(), - x, - .{ .whitespace = .indent_2 }, - ) catch unreachable, - }); + + // TODO: Output images from templates } From 0675ceac02315fd8af8c93bca6cbf8635c663a2e Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Mon, 2 Mar 2026 23:26:22 -0500 Subject: [PATCH 30/72] Refactor parts of arg parsing into helpers --- src/argparse.zig | 229 +++++++++++++++++++++++++---------------------- 1 file changed, 121 insertions(+), 108 deletions(-) diff --git a/src/argparse.zig b/src/argparse.zig index 414c5157fe9..093ffce76e9 100644 --- a/src/argparse.zig +++ b/src/argparse.zig @@ -1,27 +1,16 @@ const std = @import("std"); -fn strip_optional(T: type) type { - const info = @typeInfo(T); - if (info != .optional) return T; - return strip_optional(info.optional.child); -} - -fn free_field(allocator: std.mem.Allocator, field: anytype) void { - switch (@typeInfo(@TypeOf(field))) { - .pointer => allocator.free(field), - .optional => if (field) |v| free_field(allocator, v), - .bool, .int, .float, .@"enum" => {}, - else => @compileError("Disallowed struct field type."), - } -} - +// Since parse is the only public function, these variables can be set there and +// used globally. var stdout: *std.Io.Writer = undefined; var arena: std.heap.ArenaAllocator = undefined; +var allocator: std.mem.Allocator = undefined; -pub fn parse(allocator: std.mem.Allocator, T: type) !T { +pub fn parse(gpa: std.mem.Allocator, T: type) !T { var stdout_writer = std.fs.File.stdout().writer(&.{}); stdout = &stdout_writer.interface; + allocator = gpa; arena = .init(allocator); defer arena.deinit(); const a = arena.allocator(); @@ -32,109 +21,135 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { errdefer { inline for (fields, seen) |field, seen_field| { if (seen_field) { - free_field(allocator, @field(result, field.name)); + free_field(@field(result, field.name)); } } } const args = try std.process.argsAlloc(a); defer std.process.argsFree(a, args); - { - var i: usize = 1; - args: while (i < args.len) : (i += 1) { - const raw_arg = args[i]; - if (std.mem.eql(u8, raw_arg, "-h") or - std.mem.eql(u8, raw_arg, "--help")) - { - try printUsage(T, args[0]); - std.process.exit(0); - } + try setFromCli(T, args, &seen, &result); + try setFromEnv(T, &seen, &result); + try setFromDefaults(T, &seen, &result); - // TODO: Handle one-letter arguments - if (!std.mem.startsWith(u8, raw_arg, "--")) { - try stdout.print("Unknown argument: '{s}'\n", .{raw_arg}); + inline for (fields, seen) |field, seen_field| { + if (!seen_field) { + if (@typeInfo(strip_optional(field.type)) == .bool) { + @field(result, field.name) = false; + } else { + try stdout.print( + "Missing required argument {s}\n", + .{field.name}, + ); try printUsage(T, args[0]); std.process.exit(1); } + } + } - const arg = try a.dupe(u8, raw_arg[2..]); - defer a.free(arg); - std.mem.replaceScalar(u8, arg, '-', '_'); - inline for (fields, &seen) |field, *seen_field| { - if (!seen_field.* and - std.ascii.eqlIgnoreCase(arg, field.name)) - { - const t = @typeInfo(strip_optional(field.type)); - if (t == .bool) { - @field(result, field.name) = true; - } else { - i += 1; - if (i >= args.len) { - try stdout.print( - "Missing required value for argument {s} {s}\n", - .{ raw_arg, field.name }, - ); - try printUsage(T, args[0]); - std.process.exit(1); - } - switch (t) { - // TODO - .int, .float, .@"enum" => comptime unreachable, - .pointer => @field( - result, - field.name, - ) = try allocator.dupe(u8, args[i]), - .bool => comptime unreachable, - else => @compileError( - "Disallowed struct field type.", - ), - } - } - seen_field.* = true; - continue :args; - } - } + return result; +} +fn setFromCli( + T: type, + args: []const []const u8, + seen: []bool, + result: *T, +) !void { + const a = arena.allocator(); + var i: usize = 1; + args: while (i < args.len) : (i += 1) { + const raw_arg = args[i]; + if (std.mem.eql(u8, raw_arg, "-h") or + std.mem.eql(u8, raw_arg, "--help")) + { + try printUsage(T, args[0]); + std.process.exit(0); + } + + // TODO: Handle one-letter arguments + if (!std.mem.startsWith(u8, raw_arg, "--")) { try stdout.print("Unknown argument: '{s}'\n", .{raw_arg}); try printUsage(T, args[0]); std.process.exit(1); } - } - { - var env = try std.process.getEnvMap(a); - defer env.deinit(); - var iterator = env.iterator(); - while (iterator.next()) |entry| { - const key = try a.dupe(u8, entry.key_ptr.*); - defer a.free(key); - std.mem.replaceScalar(u8, key, '-', '_'); - inline for (fields, &seen) |field, *seen_field| { - if (!seen_field.* and - std.ascii.eqlIgnoreCase(key, field.name)) - { - switch (@typeInfo(strip_optional(field.type))) { - .bool => { - const value = try a.dupe(u8, entry.value_ptr.*); - defer a.free(value); - @field(result, field.name) = value.len > 0 and - !std.ascii.eqlIgnoreCase(value, "false"); - }, + const arg = try a.dupe(u8, raw_arg[2..]); + defer a.free(arg); + std.mem.replaceScalar(u8, arg, '-', '_'); + inline for (@typeInfo(T).@"struct".fields, seen) |field, *seen_field| { + if (!seen_field.* and std.ascii.eqlIgnoreCase(arg, field.name)) { + const t = @typeInfo(strip_optional(field.type)); + if (t == .bool) { + @field(result, field.name) = true; + } else { + i += 1; + if (i >= args.len) { + try stdout.print( + "Missing required value for argument {s} {s}\n", + .{ raw_arg, field.name }, + ); + try printUsage(T, args[0]); + std.process.exit(1); + } + switch (t) { // TODO .int, .float, .@"enum" => comptime unreachable, .pointer => @field( result, field.name, - ) = try allocator.dupe(u8, entry.value_ptr.*), - else => @compileError("Disallowed struct field type."), + ) = try allocator.dupe(u8, args[i]), + .bool => comptime unreachable, + else => @compileError( + "Disallowed struct field type.", + ), } - seen_field.* = true; } + seen_field.* = true; + continue :args; + } + } + + try stdout.print("Unknown argument: '{s}'\n", .{raw_arg}); + try printUsage(T, args[0]); + std.process.exit(1); + } +} + +fn setFromEnv(T: type, seen: []bool, result: *T) !void { + const a = arena.allocator(); + var env = try std.process.getEnvMap(a); + defer env.deinit(); + var iterator = env.iterator(); + while (iterator.next()) |entry| { + const key = try a.dupe(u8, entry.key_ptr.*); + defer a.free(key); + std.mem.replaceScalar(u8, key, '-', '_'); + inline for (@typeInfo(T).@"struct".fields, seen) |field, *seen_field| { + if (!seen_field.* and std.ascii.eqlIgnoreCase(key, field.name)) { + switch (@typeInfo(strip_optional(field.type))) { + .bool => { + const value = try a.dupe(u8, entry.value_ptr.*); + defer a.free(value); + @field(result, field.name) = value.len > 0 and + !std.ascii.eqlIgnoreCase(value, "false"); + }, + // TODO + .int, .float, .@"enum" => comptime unreachable, + .pointer => @field( + result, + field.name, + ) = try allocator.dupe(u8, entry.value_ptr.*), + else => @compileError("Disallowed struct field type."), + } + seen_field.* = true; } } } +} - inline for (fields, &seen) |field, *seen_field| { +fn setFromDefaults(T: type, seen: []bool, result: *T) !void { + inline for (@typeInfo(T).@"struct".fields, seen) |field, *seen_field| { if (!seen_field.*) { if (field.defaultValue()) |default| { switch (@typeInfo(strip_optional(field.type))) { @@ -151,23 +166,6 @@ pub fn parse(allocator: std.mem.Allocator, T: type) !T { } } } - - inline for (fields, seen) |field, seen_field| { - if (!seen_field) { - if (@typeInfo(strip_optional(field.type)) == .bool) { - @field(result, field.name) = false; - } else { - try stdout.print( - "Missing required argument {s}\n", - .{field.name}, - ); - try printUsage(T, args[0]); - std.process.exit(1); - } - } - } - - return result; } fn printUsage(T: type, argv0: []const u8) !void { @@ -192,3 +190,18 @@ fn printUsage(T: type, argv0: []const u8) !void { } } } + +fn strip_optional(T: type) type { + const info = @typeInfo(T); + if (info != .optional) return T; + return strip_optional(info.optional.child); +} + +fn free_field(field: anytype) void { + switch (@typeInfo(@TypeOf(field))) { + .pointer => allocator.free(field), + .optional => if (field) |v| free_field(v), + .bool, .int, .float, .@"enum" => {}, + else => @compileError("Disallowed struct field type."), + } +} From 3a7f21e6713e47b811811301c98665fa23f5fc3a Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 14 Mar 2026 12:31:50 -0400 Subject: [PATCH 31/72] Track different contribution types separately --- src/main.zig | 5 ++--- src/statistics.zig | 36 ++++++++++++++++++++++++------------ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/main.zig b/src/main.zig index c2b0e95adce..1f5dcdd3a1a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -58,12 +58,11 @@ pub fn main() !void { try std.fs.cwd().createFile(path, .{}); defer out.close(); var write_buffer: [0x100]u8 = undefined; - var _writer = out.writer(&write_buffer); - const writer = &_writer.interface; + var writer = out.writer(&write_buffer); var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); - try writer.writeAll( + try writer.interface.writeAll( try std.json.Stringify.valueAlloc( arena.allocator(), stats, diff --git a/src/statistics.zig b/src/statistics.zig index a42817a1396..69248fbc841 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -3,7 +3,11 @@ const HttpClient = @import("http_client.zig"); repositories: []Repository, user: []const u8, -contributions: u32 = 0, +repo_contributions: u32 = 0, +issue_contributions: u32 = 0, +commit_contributions: u32 = 0, +pr_contributions: u32 = 0, +review_contributions: u32 = 0, var allocator: std.mem.Allocator = undefined; const Statistics = @This(); @@ -153,7 +157,16 @@ fn get_repos( arena: *std.heap.ArenaAllocator, client: *HttpClient, ) !Statistics { - var contributions: u32 = 0; + var result: Statistics = .{ + .repo_contributions = 0, + .issue_contributions = 0, + .commit_contributions = 0, + .pr_contributions = 0, + .review_contributions = 0, + + .user = undefined, + .repositories = undefined, + }; var repositories: std.ArrayList(Repository) = try .initCapacity(allocator, 32); errdefer { @@ -257,11 +270,12 @@ fn get_repos( .{ stats.commitContributionsByRepository.len, year }, ); - contributions += stats.totalRepositoryContributions; - contributions += stats.totalIssueContributions; - contributions += stats.totalCommitContributions; - contributions += stats.totalPullRequestContributions; - contributions += stats.totalPullRequestReviewContributions; + result.repo_contributions += stats.totalRepositoryContributions; + result.issue_contributions += stats.totalIssueContributions; + result.commit_contributions += stats.totalCommitContributions; + result.pr_contributions += stats.totalPullRequestContributions; + result.review_contributions += + stats.totalPullRequestReviewContributions; // TODO: if there are 100 ore more repositories, we should subdivide // the date range in half @@ -354,11 +368,9 @@ fn get_repos( } }.lessThanFn); - return .{ - .contributions = contributions, - .user = try allocator.dupe(u8, user), - .repositories = list, - }; + result.user = try allocator.dupe(u8, user); + result.repositories = list; + return result; } fn get_lines_changed( From 12191b2017a9ef192974694903e2110db6df8b0f Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 14 Mar 2026 19:01:57 -0400 Subject: [PATCH 32/72] Add verbose CLI flag --- src/main.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index 1f5dcdd3a1a..b85ec9a4799 100644 --- a/src/main.zig +++ b/src/main.zig @@ -30,6 +30,7 @@ fn logFn( const Args = struct { api_key: []const u8, json_output_file: ?[]const u8 = null, + verbose: bool = false, pub fn deinit(self: @This()) void { allocator.free(self.api_key); @@ -44,6 +45,9 @@ pub fn main() !void { const args = try argparse.parse(allocator, Args); defer args.deinit(); + if (args.verbose) { + log_level = .debug; + } var client: HttpClient = try .init(allocator, args.api_key); defer client.deinit(); @@ -57,7 +61,7 @@ pub fn main() !void { else try std.fs.cwd().createFile(path, .{}); defer out.close(); - var write_buffer: [0x100]u8 = undefined; + var write_buffer: [64 * 1024]u8 = undefined; var writer = out.writer(&write_buffer); var arena = std.heap.ArenaAllocator.init(allocator); From 08f3295e9e6a75c3e6589d5ac3217e3c17c35040 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 14 Mar 2026 19:25:48 -0400 Subject: [PATCH 33/72] Don't forget to flush --- src/main.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.zig b/src/main.zig index b85ec9a4799..01674649155 100644 --- a/src/main.zig +++ b/src/main.zig @@ -73,6 +73,7 @@ pub fn main() !void { .{ .whitespace = .indent_2 }, ), ); + try writer.interface.flush(); } // TODO: Output images from templates From 7b8c6826c275db3f19cb2af85747e2210d59b5a3 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 14 Mar 2026 19:50:05 -0400 Subject: [PATCH 34/72] Pull user name in addition to username --- src/statistics.zig | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/statistics.zig b/src/statistics.zig index 69248fbc841..d56ab87f5bd 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -3,6 +3,7 @@ const HttpClient = @import("http_client.zig"); repositories: []Repository, user: []const u8, +name: []const u8, repo_contributions: u32 = 0, issue_contributions: u32 = 0, commit_contributions: u32 = 0, @@ -115,17 +116,19 @@ pub fn deinit(self: Statistics) void { } allocator.free(self.repositories); allocator.free(self.user); + allocator.free(self.name); } -fn get_years( +fn get_basic_info( client: *HttpClient, alloc: std.mem.Allocator, -) !struct { []u32, []const u8 } { +) !struct { []u32, []const u8, ?[]const u8 } { std.log.info("Getting contribution years...", .{}); const response, const status = try client.graphql( \\query { \\ viewer { \\ login + \\ name \\ contributionsCollection { \\ contributionYears \\ } @@ -142,6 +145,7 @@ fn get_years( const parsed = (try std.json.parseFromSliceLeaky( struct { data: struct { viewer: struct { login: []const u8, + name: ?[]const u8, contributionsCollection: struct { contributionYears: []u32, }, @@ -150,7 +154,11 @@ fn get_years( response, .{ .ignore_unknown_fields = true }, )).data.viewer; - return .{ parsed.contributionsCollection.contributionYears, parsed.login }; + return .{ + parsed.contributionsCollection.contributionYears, + parsed.login, + parsed.name, + }; } fn get_repos( @@ -158,13 +166,8 @@ fn get_repos( client: *HttpClient, ) !Statistics { var result: Statistics = .{ - .repo_contributions = 0, - .issue_contributions = 0, - .commit_contributions = 0, - .pr_contributions = 0, - .review_contributions = 0, - .user = undefined, + .name = undefined, .repositories = undefined, }; var repositories: std.ArrayList(Repository) = @@ -178,8 +181,13 @@ fn get_repos( var seen: std.StringHashMap(bool) = .init(arena.allocator()); defer seen.deinit(); - const years, const user = try get_years(client, arena.allocator()); - std.log.info("Getting data for user {s}...", .{user}); + const years, const user, const name = + try get_basic_info(client, arena.allocator()); + if (name) |n| { + std.log.info("Getting data for {s} ({s})...", .{ n, user }); + } else { + std.log.info("Getting data for user {s}...", .{user}); + } for (years) |year| { std.log.info("Getting data from {d}...", .{year}); var response, var status = try client.graphql( @@ -369,6 +377,9 @@ fn get_repos( }.lessThanFn); result.user = try allocator.dupe(u8, user); + errdefer allocator.free(result.user); + result.name = try allocator.dupe(u8, name orelse user); + errdefer allocator.free(result.name); result.repositories = list; return result; } From c963e025c416560eab304969bbb96f6c7ae88827 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 18 Mar 2026 22:20:37 -0400 Subject: [PATCH 35/72] Fix Chrome/Safari rendering bug (close #57, #71) --- templates/languages.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/languages.svg b/templates/languages.svg index 66b9b62844a..a3754df18be 100644 --- a/templates/languages.svg +++ b/templates/languages.svg @@ -65,7 +65,7 @@ li { } div.ellipsis { - height: 100%; + height: 176px; overflow: hidden; text-overflow: ellipsis; } From 44576f266a2a1244331a8fbfd86c2a3ccb104ab5 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Thu, 19 Mar 2026 23:12:57 -0400 Subject: [PATCH 36/72] Add glob matching with tests --- build.zig | 6 ++++ src/glob.zig | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 6 ++++ 3 files changed, 101 insertions(+) create mode 100644 src/glob.zig diff --git a/build.zig b/build.zig index cc6b9c6dd23..40721988646 100644 --- a/build.zig +++ b/build.zig @@ -23,4 +23,10 @@ pub fn build(b: *std.Build) void { if (b.args) |args| { run_cmd.addArgs(args); } + + const tests = b.addTest(.{ .root_module = exe.root_module }); + const run_tests = b.addRunArtifact(tests); + run_tests.step.dependOn(b.getInstallStep()); + const test_step = b.step("test", "Run the tests"); + test_step.dependOn(&run_tests.step); } diff --git a/src/glob.zig b/src/glob.zig new file mode 100644 index 00000000000..8a5e049dc96 --- /dev/null +++ b/src/glob.zig @@ -0,0 +1,89 @@ +const std = @import("std"); + +/// Recursive-backtracking glob matching. Potentially very slow if there are a +/// lot of globs. Good enough for now, though. (If it's good enough for the GNU +/// glob function, it's good enough for me.) +/// +/// Max recursion depth is the number of asterisks in the globbing pattern plus +/// one. +pub fn match(pattern: []const u8, s: []const u8) bool { + if (std.mem.indexOfScalar(u8, pattern, '*')) |star_offset| { + if (!std.mem.startsWith(u8, s, pattern[0..star_offset])) { + return false; + } + const rest = pattern[star_offset + 1 ..]; + for (0..s.len + 1) |glob_end| { + if (match(rest, s[glob_end..])) { + return true; + } + } + return false; + } else { + return std.mem.eql(u8, pattern, s); + } +} + +pub fn matchAny(patterns: []const []const u8, s: []const u8) bool { + for (patterns) |pattern| { + if (match(pattern, s)) { + return true; + } + } + return false; +} + +test match { + const testing = std.testing; + + try testing.expect(match("", "")); + try testing.expect(match("*", "")); + try testing.expect(match("**", "")); + try testing.expect(match("***", "")); + + try testing.expect(match("*", "a")); + try testing.expect(match("**", "a")); + try testing.expect(match("***", "a")); + + try testing.expect(match("*", "abcd")); + try testing.expect(match("**", "abcd")); + try testing.expect(match("****", "abcd")); + try testing.expect(match("****d", "abcd")); + try testing.expect(match("a****", "abcd")); + try testing.expect(match("a****d", "abcd")); + try testing.expect(!match("****c", "abcd")); + + try testing.expect(match("abc", "abc")); + try testing.expect(!match("abc", "abcd")); + try testing.expect(!match("abc", "dabc")); + try testing.expect(!match("abc", "dabcd")); + + try testing.expect(match("*abc", "dabc")); + try testing.expect(!match("*abc", "dabcd")); + + try testing.expect(match("abc*", "abcd")); + try testing.expect(!match("abc*", "dabcd")); + + try testing.expect(match("*abc*", "abc")); + try testing.expect(match("*abc*", "dabc")); + try testing.expect(match("*abc*", "abcd")); + try testing.expect(match("*abc*", "dabcd")); + + try testing.expect(!match("*c*", "this is a test")); + try testing.expect(match("*e*", "this is a test")); + + try testing.expect(match("som*thing", "something")); + try testing.expect(match("som*thing", "someeeething")); + try testing.expect(match("som*thing", "som thing")); + try testing.expect(match("som*thing", "somabcthing")); + try testing.expect(match("som*thing", "somthing")); + + try testing.expect(match("s*a*s*s*s*s*s*s*s*s", "sssssssssssssassssssssss")); + try testing.expect(match("s*s*s*s*s*s*s*s*s*s", "sssssssssssssassssssssss")); + try testing.expect(match("s*s*s*s*s*s*s*s*a*s", "sssssssssssssassssssssss")); + + // Globbing here doesn't separate on slashes like globbing in the shell + try testing.expect(match("*", "///")); + try testing.expect(match("*", "/asdf//")); + try testing.expect(match("/*sdf/*/*", "/asdf//")); + try testing.expect(match("/*sdf/*", "/asdf//")); +} diff --git a/src/main.zig b/src/main.zig index 01674649155..6444ae40128 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,6 +1,7 @@ const builtin = @import("builtin"); const std = @import("std"); const argparse = @import("argparse.zig"); +const glob = @import("glob.zig"); const HttpClient = @import("http_client.zig"); const Statistics = @import("statistics.zig"); @@ -77,4 +78,9 @@ pub fn main() !void { } // TODO: Output images from templates + _ = glob; +} + +test { + std.testing.refAllDecls(@This()); } From 8d45af07562ed0af4d175a40ec512ecf914d4f86 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Thu, 19 Mar 2026 23:12:57 -0400 Subject: [PATCH 37/72] Add glob matching with tests --- build.zig | 5 +++ src/glob.zig | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 6 ++++ 3 files changed, 100 insertions(+) create mode 100644 src/glob.zig diff --git a/build.zig b/build.zig index cc6b9c6dd23..f69ae1fce35 100644 --- a/build.zig +++ b/build.zig @@ -23,4 +23,9 @@ pub fn build(b: *std.Build) void { if (b.args) |args| { run_cmd.addArgs(args); } + + const tests = b.addTest(.{ .root_module = exe.root_module }); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run the tests"); + test_step.dependOn(&run_tests.step); } diff --git a/src/glob.zig b/src/glob.zig new file mode 100644 index 00000000000..8a5e049dc96 --- /dev/null +++ b/src/glob.zig @@ -0,0 +1,89 @@ +const std = @import("std"); + +/// Recursive-backtracking glob matching. Potentially very slow if there are a +/// lot of globs. Good enough for now, though. (If it's good enough for the GNU +/// glob function, it's good enough for me.) +/// +/// Max recursion depth is the number of asterisks in the globbing pattern plus +/// one. +pub fn match(pattern: []const u8, s: []const u8) bool { + if (std.mem.indexOfScalar(u8, pattern, '*')) |star_offset| { + if (!std.mem.startsWith(u8, s, pattern[0..star_offset])) { + return false; + } + const rest = pattern[star_offset + 1 ..]; + for (0..s.len + 1) |glob_end| { + if (match(rest, s[glob_end..])) { + return true; + } + } + return false; + } else { + return std.mem.eql(u8, pattern, s); + } +} + +pub fn matchAny(patterns: []const []const u8, s: []const u8) bool { + for (patterns) |pattern| { + if (match(pattern, s)) { + return true; + } + } + return false; +} + +test match { + const testing = std.testing; + + try testing.expect(match("", "")); + try testing.expect(match("*", "")); + try testing.expect(match("**", "")); + try testing.expect(match("***", "")); + + try testing.expect(match("*", "a")); + try testing.expect(match("**", "a")); + try testing.expect(match("***", "a")); + + try testing.expect(match("*", "abcd")); + try testing.expect(match("**", "abcd")); + try testing.expect(match("****", "abcd")); + try testing.expect(match("****d", "abcd")); + try testing.expect(match("a****", "abcd")); + try testing.expect(match("a****d", "abcd")); + try testing.expect(!match("****c", "abcd")); + + try testing.expect(match("abc", "abc")); + try testing.expect(!match("abc", "abcd")); + try testing.expect(!match("abc", "dabc")); + try testing.expect(!match("abc", "dabcd")); + + try testing.expect(match("*abc", "dabc")); + try testing.expect(!match("*abc", "dabcd")); + + try testing.expect(match("abc*", "abcd")); + try testing.expect(!match("abc*", "dabcd")); + + try testing.expect(match("*abc*", "abc")); + try testing.expect(match("*abc*", "dabc")); + try testing.expect(match("*abc*", "abcd")); + try testing.expect(match("*abc*", "dabcd")); + + try testing.expect(!match("*c*", "this is a test")); + try testing.expect(match("*e*", "this is a test")); + + try testing.expect(match("som*thing", "something")); + try testing.expect(match("som*thing", "someeeething")); + try testing.expect(match("som*thing", "som thing")); + try testing.expect(match("som*thing", "somabcthing")); + try testing.expect(match("som*thing", "somthing")); + + try testing.expect(match("s*a*s*s*s*s*s*s*s*s", "sssssssssssssassssssssss")); + try testing.expect(match("s*s*s*s*s*s*s*s*s*s", "sssssssssssssassssssssss")); + try testing.expect(match("s*s*s*s*s*s*s*s*a*s", "sssssssssssssassssssssss")); + + // Globbing here doesn't separate on slashes like globbing in the shell + try testing.expect(match("*", "///")); + try testing.expect(match("*", "/asdf//")); + try testing.expect(match("/*sdf/*/*", "/asdf//")); + try testing.expect(match("/*sdf/*", "/asdf//")); +} diff --git a/src/main.zig b/src/main.zig index 01674649155..6444ae40128 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,6 +1,7 @@ const builtin = @import("builtin"); const std = @import("std"); const argparse = @import("argparse.zig"); +const glob = @import("glob.zig"); const HttpClient = @import("http_client.zig"); const Statistics = @import("statistics.zig"); @@ -77,4 +78,9 @@ pub fn main() !void { } // TODO: Output images from templates + _ = glob; +} + +test { + std.testing.refAllDecls(@This()); } From 58b198be68a1f2061ac4297d1f62de6d75f7f272 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 21 Mar 2026 11:55:23 -0400 Subject: [PATCH 38/72] Clean up and add some globbing tests --- src/glob.zig | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/glob.zig b/src/glob.zig index 8a5e049dc96..01de192828d 100644 --- a/src/glob.zig +++ b/src/glob.zig @@ -4,8 +4,7 @@ const std = @import("std"); /// lot of globs. Good enough for now, though. (If it's good enough for the GNU /// glob function, it's good enough for me.) /// -/// Max recursion depth is the number of asterisks in the globbing pattern plus -/// one. +/// Max recursion depth is the number of stars in the globbing pattern plus one. pub fn match(pattern: []const u8, s: []const u8) bool { if (std.mem.indexOfScalar(u8, pattern, '*')) |star_offset| { if (!std.mem.startsWith(u8, s, pattern[0..star_offset])) { @@ -77,9 +76,18 @@ test match { try testing.expect(match("som*thing", "somabcthing")); try testing.expect(match("som*thing", "somthing")); - try testing.expect(match("s*a*s*s*s*s*s*s*s*s", "sssssssssssssassssssssss")); - try testing.expect(match("s*s*s*s*s*s*s*s*s*s", "sssssssssssssassssssssss")); - try testing.expect(match("s*s*s*s*s*s*s*s*a*s", "sssssssssssssassssssssss")); + try testing.expect(match( + "s*a" ++ "*s" ** 8, + "s" ** 10 ++ "a" ++ "s" ** 10, + )); + try testing.expect(match( + "s" ++ "*s" ** 8, + "s" ** 10 ++ "a" ++ "s" ** 10, + )); + try testing.expect(match( + "s*" ** 8 ++ "a*s", + "s" ** 10 ++ "a" ++ "s" ** 10, + )); // Globbing here doesn't separate on slashes like globbing in the shell try testing.expect(match("*", "///")); @@ -87,3 +95,11 @@ test match { try testing.expect(match("/*sdf/*/*", "/asdf//")); try testing.expect(match("/*sdf/*", "/asdf//")); } + +test matchAny { + const testing = std.testing; + + try testing.expect(matchAny(&.{ "*waw", "wew*", "wow", "www" }, "wow")); + try testing.expect(!matchAny(&.{ "*waw", "wew*", "www" }, "wow")); + try testing.expect(matchAny(&.{ "w*w", "www" }, "wow")); +} From 38b2de5f5b01762ef28c56eccebfac879ca3dd99 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 21 Mar 2026 12:52:03 -0400 Subject: [PATCH 39/72] Add --silent CLI arg --- src/main.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index 6444ae40128..d79665ad832 100644 --- a/src/main.zig +++ b/src/main.zig @@ -31,6 +31,7 @@ fn logFn( const Args = struct { api_key: []const u8, json_output_file: ?[]const u8 = null, + silent: bool = false, verbose: bool = false, pub fn deinit(self: @This()) void { @@ -46,7 +47,9 @@ pub fn main() !void { const args = try argparse.parse(allocator, Args); defer args.deinit(); - if (args.verbose) { + if (args.silent) { + log_level = .err; + } else if (args.verbose) { log_level = .debug; } From 6f895bbcae016d67171e0972176581d70128a800 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 21 Mar 2026 13:21:40 -0400 Subject: [PATCH 40/72] Print CLI arg errors to stderror --- src/argparse.zig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/argparse.zig b/src/argparse.zig index 093ffce76e9..13366b48fee 100644 --- a/src/argparse.zig +++ b/src/argparse.zig @@ -3,12 +3,15 @@ const std = @import("std"); // Since parse is the only public function, these variables can be set there and // used globally. var stdout: *std.Io.Writer = undefined; +var stderr: *std.Io.Writer = undefined; var arena: std.heap.ArenaAllocator = undefined; var allocator: std.mem.Allocator = undefined; pub fn parse(gpa: std.mem.Allocator, T: type) !T { var stdout_writer = std.fs.File.stdout().writer(&.{}); stdout = &stdout_writer.interface; + var stderr_writer = std.fs.File.stderr().writer(&.{}); + stderr = &stderr_writer.interface; allocator = gpa; arena = .init(allocator); @@ -37,7 +40,7 @@ pub fn parse(gpa: std.mem.Allocator, T: type) !T { if (@typeInfo(strip_optional(field.type)) == .bool) { @field(result, field.name) = false; } else { - try stdout.print( + try stderr.print( "Missing required argument {s}\n", .{field.name}, ); @@ -69,7 +72,7 @@ fn setFromCli( // TODO: Handle one-letter arguments if (!std.mem.startsWith(u8, raw_arg, "--")) { - try stdout.print("Unknown argument: '{s}'\n", .{raw_arg}); + try stderr.print("Unknown argument: '{s}'\n", .{raw_arg}); try printUsage(T, args[0]); std.process.exit(1); } @@ -85,7 +88,7 @@ fn setFromCli( } else { i += 1; if (i >= args.len) { - try stdout.print( + try stderr.print( "Missing required value for argument {s} {s}\n", .{ raw_arg, field.name }, ); @@ -110,7 +113,7 @@ fn setFromCli( } } - try stdout.print("Unknown argument: '{s}'\n", .{raw_arg}); + try stderr.print("Unknown argument: '{s}'\n", .{raw_arg}); try printUsage(T, args[0]); std.process.exit(1); } From 5a627a0f7cb4dba1c4ee979dc7fa8c3d886536f6 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 21 Mar 2026 13:50:27 -0400 Subject: [PATCH 41/72] Add additional error checking to argparse --- src/argparse.zig | 13 ++++++++++++- src/main.zig | 31 +++++++++++++++++++++++++------ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/argparse.zig b/src/argparse.zig index 13366b48fee..8631cdb99f3 100644 --- a/src/argparse.zig +++ b/src/argparse.zig @@ -7,7 +7,11 @@ var stderr: *std.Io.Writer = undefined; var arena: std.heap.ArenaAllocator = undefined; var allocator: std.mem.Allocator = undefined; -pub fn parse(gpa: std.mem.Allocator, T: type) !T { +pub fn parse( + gpa: std.mem.Allocator, + T: type, + errorCheck: ?fn (args: T, stderr: *std.Io.Writer) anyerror!bool, +) !T { var stdout_writer = std.fs.File.stdout().writer(&.{}); stdout = &stdout_writer.interface; var stderr_writer = std.fs.File.stderr().writer(&.{}); @@ -50,6 +54,13 @@ pub fn parse(gpa: std.mem.Allocator, T: type) !T { } } + if (errorCheck) |check| { + if (!(try check(result, stderr))) { + try printUsage(T, args[0]); + std.process.exit(1); + } + } + return result; } diff --git a/src/main.zig b/src/main.zig index d79665ad832..b7011023cfd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -29,13 +29,14 @@ fn logFn( } const Args = struct { - api_key: []const u8, + api_key: ?[]const u8 = null, + json_input_file: ?[]const u8 = null, json_output_file: ?[]const u8 = null, silent: bool = false, verbose: bool = false, pub fn deinit(self: @This()) void { - allocator.free(self.api_key); + if (self.api_key) |key| allocator.free(key); if (self.json_output_file) |output| allocator.free(output); } }; @@ -45,7 +46,18 @@ pub fn main() !void { defer _ = gpa.deinit(); allocator = gpa.allocator(); - const args = try argparse.parse(allocator, Args); + const args = try argparse.parse(allocator, Args, struct { + fn errorCheck(a: Args, stderr: *std.Io.Writer) !bool { + if (a.api_key == null and a.json_input_file == null) { + try stderr.print( + "You must pass either an input file or an API key.\n", + .{}, + ); + return false; + } + return true; + } + }.errorCheck); defer args.deinit(); if (args.silent) { log_level = .err; @@ -53,9 +65,16 @@ pub fn main() !void { log_level = .debug; } - var client: HttpClient = try .init(allocator, args.api_key); - defer client.deinit(); - const stats = try Statistics.init(&client, allocator); + var stats: Statistics = undefined; + if (args.json_input_file) |infile| { + // TODO + _ = infile; + return error.NotImplementedYet; + } else if (args.api_key) |api_key| { + var client: HttpClient = try .init(allocator, api_key); + defer client.deinit(); + stats = try Statistics.init(&client, allocator); + } else unreachable; defer stats.deinit(); if (args.json_output_file) |path| { From 44214fa3571a370c6a2d2e1e621fc9a306e3fb95 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 21 Mar 2026 17:26:12 -0400 Subject: [PATCH 42/72] Optionally initialize stats from JSON file --- src/main.zig | 20 ++++++++++++--- src/statistics.zig | 62 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/main.zig b/src/main.zig index b7011023cfd..20dbd1c8c12 100644 --- a/src/main.zig +++ b/src/main.zig @@ -37,6 +37,7 @@ const Args = struct { pub fn deinit(self: @This()) void { if (self.api_key) |key| allocator.free(key); + if (self.json_input_file) |input| allocator.free(input); if (self.json_output_file) |output| allocator.free(output); } }; @@ -66,10 +67,21 @@ pub fn main() !void { } var stats: Statistics = undefined; - if (args.json_input_file) |infile| { - // TODO - _ = infile; - return error.NotImplementedYet; + if (args.json_input_file) |path| { + const in = + if (std.mem.eql(u8, path, "-")) + std.fs.File.stdin() + else + try std.fs.cwd().openFile(path, .{}); + defer in.close(); + var read_buffer: [64 * 1024]u8 = undefined; + var reader = in.reader(&read_buffer); + // TODO: Create a scanner from the reader instead of reading the whole + // file into memory + const data = + try (&reader.interface).allocRemaining(allocator, .unlimited); + defer allocator.free(data); + stats = try Statistics.initFromJson(allocator, data); } else if (args.api_key) |api_key| { var client: HttpClient = try .init(allocator, api_key); defer client.deinit(); diff --git a/src/statistics.zig b/src/statistics.zig index d56ab87f5bd..e9c7d24644a 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -13,17 +13,6 @@ review_contributions: u32 = 0, var allocator: std.mem.Allocator = undefined; const Statistics = @This(); -const Language = struct { - name: []const u8, - size: u32, - color: []const u8, - - pub fn deinit(self: @This()) void { - allocator.free(self.name); - allocator.free(self.color); - } -}; - const Repository = struct { name: []const u8, stars: u32, @@ -99,6 +88,17 @@ const Repository = struct { } }; +const Language = struct { + name: []const u8, + size: u32, + color: []const u8, + + pub fn deinit(self: @This()) void { + allocator.free(self.name); + allocator.free(self.color); + } +}; + pub fn init(client: *HttpClient, a: std.mem.Allocator) !Statistics { allocator = a; var arena = std.heap.ArenaAllocator.init(allocator); @@ -110,6 +110,20 @@ pub fn init(client: *HttpClient, a: std.mem.Allocator) !Statistics { return self; } +pub fn initFromJson(a: std.mem.Allocator, s: []const u8) !Statistics { + allocator = a; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const parsed = try std.json.parseFromSliceLeaky( + Statistics, + arena.allocator(), + s, + .{ .ignore_unknown_fields = true }, + ); + return try deepcopy(allocator, parsed); +} + pub fn deinit(self: Statistics) void { for (self.repositories) |repository| { repository.deinit(); @@ -441,3 +455,29 @@ fn get_lines_changed( } } } + +fn deepcopy(a: std.mem.Allocator, o: anytype) !@TypeOf(o) { + return switch (@typeInfo(@TypeOf(o))) { + .pointer => |p| switch (p.size) { + .slice => v: { + const result = try a.dupe(p.child, o); + for (o, result) |src, *dest| { + dest.* = try deepcopy(a, src); + } + break :v result; + }, + // Only slices in this struct + else => comptime unreachable, + }, + .@"struct" => |s| v: { + var result = o; + inline for (s.fields) |field| { + @field(result, field.name) = + try deepcopy(a, @field(o, field.name)); + } + break :v result; + }, + .optional => if (o) |v| try deepcopy(a, v) else null, + else => o, + }; +} From 03a74b05174596aba169dfc85d5a6ff81ce0232a Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Mon, 23 Mar 2026 01:12:11 -0400 Subject: [PATCH 43/72] Fix minor bugs --- src/http_client.zig | 9 ++------- src/statistics.zig | 5 ++++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/http_client.zig b/src/http_client.zig index d3ea8c4860d..8b2c3a9dacc 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -8,7 +8,6 @@ gpa: std.mem.Allocator, arena: *std.heap.ArenaAllocator, client: std.http.Client, bearer: []const u8, -last_request: ?i64 = null, const Self = @This(); const Response = struct { []const u8, std.http.Status }; @@ -41,8 +40,7 @@ pub fn get( self.arena.allocator(), 1024, ); - defer writer.deinit(); - const now = std.time.timestamp(); + errdefer writer.deinit(); const status = (try (self.client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.writer, @@ -65,7 +63,6 @@ pub fn get( }, else => err, })).status; - self.last_request = now; return .{ try writer.toOwnedSlice(), status }; } @@ -79,8 +76,7 @@ pub fn post( self.arena.allocator(), 1024, ); - defer writer.deinit(); - const now = std.time.timestamp(); + errdefer writer.deinit(); const status = (try (self.client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.writer, @@ -103,7 +99,6 @@ pub fn post( }, else => err, })).status; - self.last_request = now; return .{ try writer.toOwnedSlice(), status }; } diff --git a/src/statistics.zig b/src/statistics.zig index e9c7d24644a..5845b4a9af1 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -321,6 +321,8 @@ fn get_repos( }; if (raw_repo.languages) |repo_languages| { + // TODO: Properly free partially initialized memory when any try + // fails in this block if (repo_languages.edges) |raw_languages| { repository.languages = try allocator.alloc( Language, @@ -334,7 +336,7 @@ fn get_repos( .name = try allocator.dupe(u8, raw.node.name), .size = raw.size, // TODO: Add sensible default color - .color = "", + .color = try allocator.dupe(u8, ""), }; if (raw.node.color) |color| { language.color = try allocator.dupe(u8, color); @@ -443,6 +445,7 @@ fn get_lines_changed( // Exponential backoff (in expectation) with jitter item.delay += std.crypto.random.intRangeAtMost(i64, 2, item.delay); + item.delay = @min(item.delay, 240); try q.add(item); }, else => |status| { From 4017075ded494b110cb9f302e106ca408f735bd0 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 09:35:49 -0400 Subject: [PATCH 44/72] Add HTTP client retry limit --- src/http_client.zig | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/http_client.zig b/src/http_client.zig index 8b2c3a9dacc..36b6c8eb1da 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -35,7 +35,12 @@ pub fn get( url: []const u8, headers: std.http.Client.Request.Headers, extra_headers: []const std.http.Header, + retries: isize, ) !Response { + if (retries <= -1) { + return error.TooManyRetries; + } + var writer = try std.Io.Writer.Allocating.initCapacity( self.arena.allocator(), 1024, @@ -50,7 +55,7 @@ pub fn get( error.HttpConnectionClosing => { // Handle a Zig HTTP bug where keep-alive connections are closed by // the server after a timeout, but the client doesn't handle it - // properly. For now we nuke the whole client (and associate + // properly. For now we nuke the whole client (and associated // connection pool) and make a new one, but there might be a better // way to handle this. std.log.debug( @@ -59,7 +64,7 @@ pub fn get( ); self.client.deinit(); self.client = .{ .allocator = self.arena.allocator() }; - return self.get(url, headers, extra_headers); + return self.get(url, headers, extra_headers, retries - 1); }, else => err, })).status; @@ -71,7 +76,12 @@ pub fn post( url: []const u8, body: []const u8, headers: std.http.Client.Request.Headers, + retries: isize, ) !Response { + if (retries <= -1) { + return error.TooManyRetries; + } + var writer = try std.Io.Writer.Allocating.initCapacity( self.arena.allocator(), 1024, @@ -86,7 +96,7 @@ pub fn post( error.HttpConnectionClosing => { // Handle a Zig HTTP bug where keep-alive connections are closed by // the server after a timeout, but the client doesn't handle it - // properly. For now we nuke the whole client (and associate + // properly. For now we nuke the whole client (and associated // connection pool) and make a new one, but there might be a better // way to handle this. std.log.debug( @@ -95,7 +105,7 @@ pub fn post( ); self.client.deinit(); self.client = .{ .allocator = self.arena.allocator() }; - return self.post(url, body, headers); + return self.post(url, body, headers, retries - 1); }, else => err, })).status; @@ -125,6 +135,7 @@ pub fn graphql( .authorization = .{ .override = self.bearer }, .content_type = .{ .override = "application/json" }, }, + 8, ); } @@ -139,5 +150,6 @@ pub fn rest( .content_type = .{ .override = "application/json" }, }, &.{.{ .name = "X-GitHub-Api-Version", .value = "2022-11-28" }}, + 8, ); } From 804234d0dacb811fa309946a88c4151a50ac7b9d Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 09:46:53 -0400 Subject: [PATCH 45/72] Improve serialization of GraphQL variables structs --- src/http_client.zig | 10 +++------- src/statistics.zig | 23 ++++++++++++----------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/http_client.zig b/src/http_client.zig index 36b6c8eb1da..f735593b1eb 100644 --- a/src/http_client.zig +++ b/src/http_client.zig @@ -112,22 +112,18 @@ pub fn post( return .{ try writer.toOwnedSlice(), status }; } -const Query = struct { - query: []const u8, - variables: ?[]const u8, -}; - pub fn graphql( self: *Self, body: []const u8, - variables: ?[]const u8, + variables: anytype, ) !Response { var arena = std.heap.ArenaAllocator.init(self.arena.allocator()); defer arena.deinit(); const allocator = arena.allocator(); + return try self.post( "https://api.github.com/graphql", - try std.json.Stringify.valueAlloc(allocator, Query{ + try std.json.Stringify.valueAlloc(allocator, .{ .query = body, .variables = variables, }, .{}), diff --git a/src/statistics.zig b/src/statistics.zig index 5845b4a9af1..2ca1653107d 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -236,17 +236,18 @@ fn get_repos( \\ } \\} , - // NOTE: Replace with actual JSON serialization if using more - // complex tyeps. This is fine as long as we're only using numbers. - try std.fmt.allocPrint( - arena.allocator(), - \\{{ - \\ "from": "{d}-01-01T00:00:00Z", - \\ "to": "{d}-01-01T00:00:00Z" - \\}} - , - .{ year, year + 1 }, - ), + .{ + .from = try std.fmt.allocPrint( + arena.allocator(), + "{d}-01-01T00:00:00Z", + .{year}, + ), + .to = try std.fmt.allocPrint( + arena.allocator(), + "{d}-01-01T00:00:00Z", + .{year + 1}, + ), + }, ); if (status != .ok) { std.log.err( From ff92a409744b8578f6588e81a47f7d36fe4e8d23 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 10:01:49 -0400 Subject: [PATCH 46/72] Add pathologically slow test case --- src/glob.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/glob.zig b/src/glob.zig index 01de192828d..5315f9a38ec 100644 --- a/src/glob.zig +++ b/src/glob.zig @@ -88,6 +88,8 @@ test match { "s*" ** 8 ++ "a*s", "s" ** 10 ++ "a" ++ "s" ** 10, )); + // Trigger slow (exponential) worst-case + try testing.expect(!match("s*" ** 8 ++ "a", "s" ** 30)); // Globbing here doesn't separate on slashes like globbing in the shell try testing.expect(match("*", "///")); From 854504eef7d904169c22cdf601ae705c4928547c Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 10:01:56 -0400 Subject: [PATCH 47/72] Fix color memory leak --- src/statistics.zig | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/statistics.zig b/src/statistics.zig index 2ca1653107d..21761deca0a 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -91,11 +91,11 @@ const Repository = struct { const Language = struct { name: []const u8, size: u32, - color: []const u8, + color: ?[]const u8 = null, pub fn deinit(self: @This()) void { allocator.free(self.name); - allocator.free(self.color); + if (self.color) |color| allocator.free(color); } }; @@ -300,7 +300,7 @@ fn get_repos( result.review_contributions += stats.totalPullRequestReviewContributions; - // TODO: if there are 100 ore more repositories, we should subdivide + // TODO: if there are 100 or more repositories, we should subdivide // the date range in half for (stats.commitContributionsByRepository) |x| { @@ -336,8 +336,6 @@ fn get_repos( language.* = .{ .name = try allocator.dupe(u8, raw.node.name), .size = raw.size, - // TODO: Add sensible default color - .color = try allocator.dupe(u8, ""), }; if (raw.node.color) |color| { language.color = try allocator.dupe(u8, color); From 0a737f5f87b0e1eaa73be520b90ece1756cae87e Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 14:07:10 -0400 Subject: [PATCH 48/72] Parse excluded repos and languages --- src/main.zig | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main.zig b/src/main.zig index 20dbd1c8c12..a4d1a589c01 100644 --- a/src/main.zig +++ b/src/main.zig @@ -34,11 +34,15 @@ const Args = struct { json_output_file: ?[]const u8 = null, silent: bool = false, verbose: bool = false, + excluded_repos: ?[]const u8 = null, + excluded_langs: ?[]const u8 = null, pub fn deinit(self: @This()) void { - if (self.api_key) |key| allocator.free(key); - if (self.json_input_file) |input| allocator.free(input); - if (self.json_output_file) |output| allocator.free(output); + if (self.api_key) |s| allocator.free(s); + if (self.json_input_file) |s| allocator.free(s); + if (self.json_output_file) |s| allocator.free(s); + if (self.excluded_repos) |s| allocator.free(s); + if (self.excluded_langs) |s| allocator.free(s); } }; @@ -65,6 +69,26 @@ pub fn main() !void { } else if (args.verbose) { log_level = .debug; } + const excluded_repos = if (args.excluded_repos) |excluded| excluded: { + var list = try std.ArrayList([]const u8).initCapacity(allocator, 16); + errdefer list.deinit(allocator); + var iterator = std.mem.tokenizeAny(u8, excluded, ", \t\r\n|\"'\x00"); + while (iterator.next()) |pattern| { + try list.append(allocator, pattern); + } + break :excluded try list.toOwnedSlice(allocator); + } else null; + defer if (excluded_repos) |excluded| allocator.free(excluded); + const excluded_langs = if (args.excluded_langs) |excluded| excluded: { + var list = try std.ArrayList([]const u8).initCapacity(allocator, 16); + errdefer list.deinit(allocator); + var iterator = std.mem.tokenizeAny(u8, excluded, ", \t\r\n|\"'\x00"); + while (iterator.next()) |pattern| { + try list.append(allocator, pattern); + } + break :excluded try list.toOwnedSlice(allocator); + } else null; + defer if (excluded_langs) |excluded| allocator.free(excluded); var stats: Statistics = undefined; if (args.json_input_file) |path| { From 9fb4ffa4a44995834f5e1248acc7f70f53e49acf Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 14:49:03 -0400 Subject: [PATCH 49/72] Calculate language totals with exclusions --- src/glob.zig | 7 +++++-- src/main.zig | 26 ++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/glob.zig b/src/glob.zig index 5315f9a38ec..a296b16c5cf 100644 --- a/src/glob.zig +++ b/src/glob.zig @@ -7,7 +7,10 @@ const std = @import("std"); /// Max recursion depth is the number of stars in the globbing pattern plus one. pub fn match(pattern: []const u8, s: []const u8) bool { if (std.mem.indexOfScalar(u8, pattern, '*')) |star_offset| { - if (!std.mem.startsWith(u8, s, pattern[0..star_offset])) { + if (!(star_offset <= s.len and std.ascii.eqlIgnoreCase( + s[0..star_offset], + pattern[0..star_offset], + ))) { return false; } const rest = pattern[star_offset + 1 ..]; @@ -18,7 +21,7 @@ pub fn match(pattern: []const u8, s: []const u8) bool { } return false; } else { - return std.mem.eql(u8, pattern, s); + return std.ascii.eqlIgnoreCase(pattern, s); } } diff --git a/src/main.zig b/src/main.zig index a4d1a589c01..a9623c34a59 100644 --- a/src/main.zig +++ b/src/main.zig @@ -82,9 +82,9 @@ pub fn main() !void { const excluded_langs = if (args.excluded_langs) |excluded| excluded: { var list = try std.ArrayList([]const u8).initCapacity(allocator, 16); errdefer list.deinit(allocator); - var iterator = std.mem.tokenizeAny(u8, excluded, ", \t\r\n|\"'\x00"); + var iterator = std.mem.tokenizeAny(u8, excluded, ",\t\r\n|\"'\x00"); while (iterator.next()) |pattern| { - try list.append(allocator, pattern); + try list.append(allocator, std.mem.trim(u8, pattern, " ")); } break :excluded try list.toOwnedSlice(allocator); } else null; @@ -135,6 +135,28 @@ pub fn main() !void { try writer.interface.flush(); } + var languages = std.StringArrayHashMap(u64).init(allocator); + defer languages.deinit(); + for (stats.repositories) |repository| { + if (glob.matchAny(excluded_repos orelse &.{}, repository.name)) { + continue; + } + if (repository.languages) |langs| for (langs) |language| { + if (glob.matchAny(excluded_langs orelse &.{}, language.name)) { + continue; + } + var total = languages.get(language.name) orelse 0; + total += language.size; + try languages.put(language.name, total); + }; + } + for ( + languages.unmanaged.entries.slice().items(.key), + languages.unmanaged.entries.slice().items(.value), + ) |key, value| { + std.debug.print("{s}: {any}\n", .{ key, value }); + } + // TODO: Output images from templates _ = glob; } From ffa8760686488934defeefca4350a7f2c398f9ce Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 15:34:16 -0400 Subject: [PATCH 50/72] Aggregate statistics --- src/main.zig | 50 +++++++++++++++++++++++++++++++++++++--------- src/statistics.zig | 2 +- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/main.zig b/src/main.zig index a9623c34a59..b5c7612b24e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -135,8 +135,43 @@ pub fn main() !void { try writer.interface.flush(); } - var languages = std.StringArrayHashMap(u64).init(allocator); - defer languages.deinit(); + var aggregate_stats: struct { + languages: std.StringArrayHashMap(u64), + contributions: usize, + stars: usize = 0, + forks: usize = 0, + lines_changed: usize = 0, + views: usize = 0, + repos: usize = 0, + } = .{ + .contributions = stats.repo_contributions + + stats.issue_contributions + + stats.commit_contributions + + stats.pr_contributions + + stats.review_contributions, + .languages = .init(allocator), + }; + defer aggregate_stats.languages.deinit(); + for (stats.repositories) |repository| { + if (glob.matchAny(excluded_repos orelse &.{}, repository.name)) { + continue; + } + aggregate_stats.stars += repository.stars; + aggregate_stats.forks += repository.forks; + aggregate_stats.lines_changed += repository.lines_changed; + aggregate_stats.views += repository.views; + aggregate_stats.repos += 1; + } + inline for (@typeInfo(@TypeOf(aggregate_stats)).@"struct".fields) |field| { + if (!std.mem.eql(u8, field.name, "languages")) { + std.debug.print("{s}: {any}\n", .{ + field.name, + @field(aggregate_stats, field.name), + }); + } + } + std.debug.print("\n", .{}); + for (stats.repositories) |repository| { if (glob.matchAny(excluded_repos orelse &.{}, repository.name)) { continue; @@ -145,20 +180,17 @@ pub fn main() !void { if (glob.matchAny(excluded_langs orelse &.{}, language.name)) { continue; } - var total = languages.get(language.name) orelse 0; + var total = aggregate_stats.languages.get(language.name) orelse 0; total += language.size; - try languages.put(language.name, total); + try aggregate_stats.languages.put(language.name, total); }; } for ( - languages.unmanaged.entries.slice().items(.key), - languages.unmanaged.entries.slice().items(.value), + aggregate_stats.languages.keys(), + aggregate_stats.languages.values(), ) |key, value| { std.debug.print("{s}: {any}\n", .{ key, value }); } - - // TODO: Output images from templates - _ = glob; } test { diff --git a/src/statistics.zig b/src/statistics.zig index 21761deca0a..d267c9f3bb1 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -18,8 +18,8 @@ const Repository = struct { stars: u32, forks: u32, languages: ?[]Language, - views: u32, lines_changed: u32, + views: u32, pub fn deinit(self: @This()) void { allocator.free(self.name); From 66a717d9c25d0da2d3adfcab8412978ce6712dbc Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 15:59:45 -0400 Subject: [PATCH 51/72] Remove unused variable --- src/main.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index b5c7612b24e..249cde60f53 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,6 @@ const builtin = @import("builtin"); const std = @import("std"); + const argparse = @import("argparse.zig"); const glob = @import("glob.zig"); @@ -15,7 +16,6 @@ var log_level: std.log.Level = switch (builtin.mode) { else => .warn, }; var allocator: std.mem.Allocator = undefined; -var user: []const u8 = undefined; fn logFn( comptime message_level: std.log.Level, From a4d51eddc8bd5f3a93be4b0c971e90ef4f300857 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 16:29:38 -0400 Subject: [PATCH 52/72] Allow excluding private repos from stats --- src/main.zig | 27 +++++++++++++-------------- src/statistics.zig | 4 ++++ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/main.zig b/src/main.zig index 249cde60f53..722e09ff4a8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -36,6 +36,7 @@ const Args = struct { verbose: bool = false, excluded_repos: ?[]const u8 = null, excluded_langs: ?[]const u8 = null, + exclude_private: bool = false, pub fn deinit(self: @This()) void { if (self.api_key) |s| allocator.free(s); @@ -153,7 +154,9 @@ pub fn main() !void { }; defer aggregate_stats.languages.deinit(); for (stats.repositories) |repository| { - if (glob.matchAny(excluded_repos orelse &.{}, repository.name)) { + if (glob.matchAny(excluded_repos orelse &.{}, repository.name) or + (args.exclude_private and repository.private)) + { continue; } aggregate_stats.stars += repository.stars; @@ -161,7 +164,16 @@ pub fn main() !void { aggregate_stats.lines_changed += repository.lines_changed; aggregate_stats.views += repository.views; aggregate_stats.repos += 1; + if (repository.languages) |langs| for (langs) |language| { + if (glob.matchAny(excluded_langs orelse &.{}, language.name)) { + continue; + } + var total = aggregate_stats.languages.get(language.name) orelse 0; + total += language.size; + try aggregate_stats.languages.put(language.name, total); + }; } + inline for (@typeInfo(@TypeOf(aggregate_stats)).@"struct".fields) |field| { if (!std.mem.eql(u8, field.name, "languages")) { std.debug.print("{s}: {any}\n", .{ @@ -172,19 +184,6 @@ pub fn main() !void { } std.debug.print("\n", .{}); - for (stats.repositories) |repository| { - if (glob.matchAny(excluded_repos orelse &.{}, repository.name)) { - continue; - } - if (repository.languages) |langs| for (langs) |language| { - if (glob.matchAny(excluded_langs orelse &.{}, language.name)) { - continue; - } - var total = aggregate_stats.languages.get(language.name) orelse 0; - total += language.size; - try aggregate_stats.languages.put(language.name, total); - }; - } for ( aggregate_stats.languages.keys(), aggregate_stats.languages.values(), diff --git a/src/statistics.zig b/src/statistics.zig index d267c9f3bb1..bdffb3d0110 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -20,6 +20,7 @@ const Repository = struct { languages: ?[]Language, lines_changed: u32, views: u32, + private: bool, pub fn deinit(self: @This()) void { allocator.free(self.name); @@ -218,6 +219,7 @@ fn get_repos( \\ nameWithOwner \\ stargazerCount \\ forkCount + \\ isPrivate \\ languages( \\ first: 100, \\ orderBy: { direction: DESC, field: SIZE } @@ -269,6 +271,7 @@ fn get_repos( nameWithOwner: []const u8, stargazerCount: u32, forkCount: u32, + isPrivate: bool, languages: ?struct { edges: ?[]struct { size: u32, @@ -316,6 +319,7 @@ fn get_repos( .name = try allocator.dupe(u8, raw_repo.nameWithOwner), .stars = raw_repo.stargazerCount, .forks = raw_repo.forkCount, + .private = raw_repo.isPrivate, .languages = null, .views = 0, .lines_changed = 0, From 510f668d09bc50ae24d925f48d9f39c2487237b4 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 18:15:12 -0400 Subject: [PATCH 53/72] Tweak README --- README.md | 154 +++++++++++++++++++----------------------------------- 1 file changed, 54 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index a24323b1cdc..865513595b7 100644 --- a/README.md +++ b/README.md @@ -11,135 +11,73 @@ https://github.community/t/support-theme-context-for-images-in-light-vs-dark-mod Generate visualizations of GitHub user and repository statistics with GitHub -Actions. Visualizations can include data for both private repositories, and for +Actions. Visualizations can include data from private repositories, and from repositories you have contributed to, but do not own. Generated images automatically switch between GitHub light theme and GitHub dark theme. + ## Background -When someone views a profile on GitHub, it is often because they are curious -about a user's open source projects and contributions. Unfortunately, that -user's stars, forks, and pinned repositories do not necessarily reflect the -contributions they make to private repositories. The data likewise does not -present a complete picture of the user's total contributions beyond the current -year. +When someone views a GitHub profile, it is often because they are curious about +the user's open source contributions. Unfortunately, that user's stars, forks, +and pinned repositories do not necessarily reflect the contributions they make +to private repositories. The data likewise does not present a complete picture +of the user's total contributions beyond the current year. This project aims to collect a variety of profile and repository statistics using the GitHub API. It then generates images that can be displayed in repository READMEs, or in a user's [Profile README](https://docs.github.com/en/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme). +It also dumps all statistics to a JSON file that can be used for further data +analysis. + +Since this project runs on GitHub Actions, no server is required to regularly +regenerate the images with updated statistics. Likewise, since the user runs the +analysis code themselves via GitHub Actions, they can use their GitHub access +token to collect statistics on private repositories that an external service +would be unable to access. -Since the project runs on GitHub Actions, no server is required to regularly -regenerate the images with updated statistics. Likewise, since the user runs -the analysis code themselves via GitHub Actions, they can use their GitHub -access token to collect statistics on private repositories that an external -service would be unable to access. ## Disclaimer -If the project is used with an access token that has sufficient permissions to -read private repositories, it may leak details about those repositories in -error messages. For example, the `aiohttp` library—used for asynchronous API -requests—may include the requested URL in exceptions, which can leak the name -of private repositories. If there is an exception caused by `aiohttp`, this -exception will be viewable in the Actions tab of the repository fork, and -anyone may be able to see the name of one or more private repositories. - -Due to some issues with the GitHub statistics API, there are some situations -where it returns inaccurate results. Specifically, the repository view count -statistics and total lines of code modified are probably somewhat inaccurate. -Unexpectedly, these values will become more accurate over time as GitHub -caches statistics for your repositories. Additionally, repositories that were -last contributed to more than a year ago may not be included in the statistics -due to limitations in the results returned by the API. - -For more information on inaccuracies, see issue -[#2](https://github.com/jstrieb/github-stats/issues/2), -[#3](https://github.com/jstrieb/github-stats/issues/3), and -[#13](https://github.com/jstrieb/github-stats/issues/13). +The GitHub statistics API returns inaccurate results in some situations: + +- Repository view count statistics often seem too low, and many referring sites + are not captured + - If you lack permissions to access the view count for a repository, it will + be tallied as zero views – this is common for external repositories where + your only contribution is making a pull request +- Total lines of code modified may be inflated – it counts changes to files like + `package.json` that may impact the line count in surprising ways +- Only repositories with commit contributions are counted, so if you only open + an issue on a repo, it will not show up in the statistics + - Repos you created and own may not be counted if you never commit to them, or + if the committer email is not connected to your GitHub account + # Installation - - -1. Create a personal access token (not the default GitHub Actions token) using - the instructions - [here](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). - Personal access token must have permissions: `read:user` and `repo`. Copy - the access token when it is generated – if you lose it, you will have to - regenerate the token. - - Some users are reporting that it can take a few minutes for the personal - access token to work. For more, see - [#30](https://github.com/jstrieb/github-stats/issues/30). -2. Create a copy of this repository by clicking - [here](https://github.com/jstrieb/github-stats/generate). Note: this is - **not** the same as forking a copy because it copies everything fresh, - without the huge commit history. -3. Go to the "Secrets" page of your copy of the repository. If this is the - README of your copy, click [this link](../../settings/secrets/actions) to go - to the "Secrets" page. Otherwise, go to the "Settings" tab of the - newly-created repository and go to the "Secrets" page (bottom left). -4. Create a new secret with the name `ACCESS_TOKEN` and paste the copied - personal access token as the value. -5. It is possible to change the type of statistics reported by adding other - repository secrets. - - To ignore certain repos, add them (in owner/name format e.g., - `jstrieb/github-stats`) separated by commas to a new secret—created as - before—called `EXCLUDED`. - - To ignore certain languages, add them (separated by commas) to a new - secret called `EXCLUDED_LANGS`. For example, to exclude HTML and TeX you - could set the value to `html,tex`. - - To show statistics only for "owned" repositories and not forks with - contributions, add an environment variable (under the `env` header in the - [main - workflow](https://github.com/jstrieb/github-stats/blob/master/.github/workflows/main.yml)) - called `EXCLUDE_FORKED_REPOS` with a value of `true`. - - These other values are added as secrets by default to prevent leaking - information about private repositories. If you're not worried about that, - you can change the values directly [in the Actions workflow - itself](https://github.com/jstrieb/github-stats/blob/05de1314b870febd44d19ad2f55d5e59d83f5857/.github/workflows/main.yml#L48-L53). -6. Go to the [Actions - Page](../../actions?query=workflow%3A"Generate+Stats+Images") and press "Run - Workflow" on the right side of the screen to generate images for the first - time. - - The images will be automatically regenerated every 24 hours, but they can - be regenerated manually by running the workflow this way. -7. Take a look at the images that have been created in the - [`generated`](generated) folder. -8. To add your statistics to your GitHub Profile README, copy and paste the - following lines of code into your markdown content. Change the `username` - value to your GitHub username. - ```md - ![](https://raw.githubusercontent.com/username/github-stats/master/generated/overview.svg#gh-dark-mode-only) - ![](https://raw.githubusercontent.com/username/github-stats/master/generated/overview.svg#gh-light-mode-only) - ``` - ```md - ![](https://raw.githubusercontent.com/username/github-stats/master/generated/languages.svg#gh-dark-mode-only) - ![](https://raw.githubusercontent.com/username/github-stats/master/generated/languages.svg#gh-light-mode-only) - ``` -9. Link back to this repository so that others can generate their own - statistics images. -10. Star this repo if you like it! +TODO # Support the Project -There are a few things you can do to support the project: +If this project is useful to you, please support it! - Star the repository (and follow me on GitHub for more) - Share and upvote on sites like Twitter, Reddit, and Hacker News - Report any bugs, glitches, or errors that you find These things motivate me to keep sharing what I build, and they provide -validation that my work is appreciated! They also help me improve the -project. Thanks in advance! +validation that my work is appreciated! They also help me improve the project. +Thanks in advance! If you are insistent on spending money to show your support, I encourage you to -instead make a generous donation to one of the following organizations. By advocating -for Internet freedoms, organizations like these help me to feel comfortable -releasing work publicly on the Web. +instead make a generous donation to one of the following organizations. By +advocating for Internet freedoms, organizations like these help me to feel +comfortable releasing work publicly on the Web. - [Electronic Frontier Foundation](https://supporters.eff.org/donate/) - [Signal Foundation](https://signal.org/donate/) @@ -147,9 +85,25 @@ releasing work publicly on the Web. - [The Internet Archive](https://archive.org/donate/index.php) +## Project Status + +This project is actively maintained, but not actively developed. In other words, +I will fix bugs, but will rarely continue adding features (if at all). If there +are no recent commits, it means that everything has been running smoothly! + +If you want to contribute to the project, please open an issue to discuss first. +Pull requests that are not discussed with me ahead of time may be ignored. It's +nothing personal, I'm just busy, and reviewing others' code is not my idea of +fun. + +Even if something were to happen to me, and I could not continue to work on the +project, it will continue to work as long as the GitHub API endpoints it uses +remain active and unchanged. + + # Related Projects - Inspired by a desire to improve upon [anuraghazra/github-readme-stats](https://github.com/anuraghazra/github-readme-stats) -- Makes use of [GitHub Octicons](https://primer.style/octicons/) to precisely - match the GitHub UI +- Uses [GitHub Octicons](https://primer.style/octicons/) to precisely match the + GitHub UI From 0f05ddf39c3b4866e43e52b59a857911453711ce Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 21:00:11 -0400 Subject: [PATCH 54/72] Add logging messages --- src/main.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main.zig b/src/main.zig index 722e09ff4a8..124d8adfdaf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -93,6 +93,7 @@ pub fn main() !void { var stats: Statistics = undefined; if (args.json_input_file) |path| { + std.log.info("Reading statistics from '{s}'", .{path}); const in = if (std.mem.eql(u8, path, "-")) std.fs.File.stdin() @@ -108,6 +109,7 @@ pub fn main() !void { defer allocator.free(data); stats = try Statistics.initFromJson(allocator, data); } else if (args.api_key) |api_key| { + std.log.info("Collecting statistics from GitHub API", .{}); var client: HttpClient = try .init(allocator, api_key); defer client.deinit(); stats = try Statistics.init(&client, allocator); @@ -115,6 +117,7 @@ pub fn main() !void { defer stats.deinit(); if (args.json_output_file) |path| { + std.log.info("Writing raw JSON data to '{s}'", .{path}); const out = if (std.mem.eql(u8, path, "-")) std.fs.File.stdout() From 0990a291bbcff9240c4983ae00c907cc993e1575 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 22:13:11 -0400 Subject: [PATCH 55/72] Move global allocators to arguments --- src/argparse.zig | 59 ++++++++++++++++++++++++++-------------------- src/main.zig | 41 ++++++++++++++++++-------------- src/statistics.zig | 30 +++++++++++------------ 3 files changed, 71 insertions(+), 59 deletions(-) diff --git a/src/argparse.zig b/src/argparse.zig index 8631cdb99f3..701e52c39b2 100644 --- a/src/argparse.zig +++ b/src/argparse.zig @@ -4,11 +4,9 @@ const std = @import("std"); // used globally. var stdout: *std.Io.Writer = undefined; var stderr: *std.Io.Writer = undefined; -var arena: std.heap.ArenaAllocator = undefined; -var allocator: std.mem.Allocator = undefined; pub fn parse( - gpa: std.mem.Allocator, + allocator: std.mem.Allocator, T: type, errorCheck: ?fn (args: T, stderr: *std.Io.Writer) anyerror!bool, ) !T { @@ -17,8 +15,7 @@ pub fn parse( var stderr_writer = std.fs.File.stderr().writer(&.{}); stderr = &stderr_writer.interface; - allocator = gpa; - arena = .init(allocator); + var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); const a = arena.allocator(); @@ -28,16 +25,16 @@ pub fn parse( errdefer { inline for (fields, seen) |field, seen_field| { if (seen_field) { - free_field(@field(result, field.name)); + free_field(allocator, @field(result, field.name)); } } } const args = try std.process.argsAlloc(a); defer std.process.argsFree(a, args); - try setFromCli(T, args, &seen, &result); - try setFromEnv(T, &seen, &result); - try setFromDefaults(T, &seen, &result); + try setFromCli(T, allocator, &arena, args, &seen, &result); + try setFromEnv(T, allocator, &arena, &seen, &result); + try setFromDefaults(T, allocator, &seen, &result); inline for (fields, seen) |field, seen_field| { if (!seen_field) { @@ -48,7 +45,7 @@ pub fn parse( "Missing required argument {s}\n", .{field.name}, ); - try printUsage(T, args[0]); + try printUsage(T, arena.allocator(), args[0]); std.process.exit(1); } } @@ -56,7 +53,7 @@ pub fn parse( if (errorCheck) |check| { if (!(try check(result, stderr))) { - try printUsage(T, args[0]); + try printUsage(T, arena.allocator(), args[0]); std.process.exit(1); } } @@ -66,6 +63,8 @@ pub fn parse( fn setFromCli( T: type, + allocator: std.mem.Allocator, + arena: *std.heap.ArenaAllocator, args: []const []const u8, seen: []bool, result: *T, @@ -77,14 +76,14 @@ fn setFromCli( if (std.mem.eql(u8, raw_arg, "-h") or std.mem.eql(u8, raw_arg, "--help")) { - try printUsage(T, args[0]); + try printUsage(T, arena.allocator(), args[0]); std.process.exit(0); } // TODO: Handle one-letter arguments if (!std.mem.startsWith(u8, raw_arg, "--")) { try stderr.print("Unknown argument: '{s}'\n", .{raw_arg}); - try printUsage(T, args[0]); + try printUsage(T, arena.allocator(), args[0]); std.process.exit(1); } @@ -103,7 +102,7 @@ fn setFromCli( "Missing required value for argument {s} {s}\n", .{ raw_arg, field.name }, ); - try printUsage(T, args[0]); + try printUsage(T, arena.allocator(), args[0]); std.process.exit(1); } switch (t) { @@ -125,12 +124,18 @@ fn setFromCli( } try stderr.print("Unknown argument: '{s}'\n", .{raw_arg}); - try printUsage(T, args[0]); + try printUsage(T, arena.allocator(), args[0]); std.process.exit(1); } } -fn setFromEnv(T: type, seen: []bool, result: *T) !void { +fn setFromEnv( + T: type, + allocator: std.mem.Allocator, + arena: *std.heap.ArenaAllocator, + seen: []bool, + result: *T, +) !void { const a = arena.allocator(); var env = try std.process.getEnvMap(a); defer env.deinit(); @@ -162,7 +167,12 @@ fn setFromEnv(T: type, seen: []bool, result: *T) !void { } } -fn setFromDefaults(T: type, seen: []bool, result: *T) !void { +fn setFromDefaults( + T: type, + allocator: std.mem.Allocator, + seen: []bool, + result: *T, +) !void { inline for (@typeInfo(T).@"struct".fields, seen) |field, *seen_field| { if (!seen_field.*) { if (field.defaultValue()) |default| { @@ -182,22 +192,21 @@ fn setFromDefaults(T: type, seen: []bool, result: *T) !void { } } -fn printUsage(T: type, argv0: []const u8) !void { - const a = arena.allocator(); +fn printUsage(T: type, allocator: std.mem.Allocator, argv0: []const u8) !void { try stdout.print("Usage: {s} [options]\n\n", .{argv0}); try stdout.print("Options:\n", .{}); const fields = @typeInfo(T).@"struct".fields; inline for (fields) |field| { switch (@typeInfo(strip_optional(field.type))) { .bool => { - const flag_version = try a.dupe(u8, field.name); - defer a.free(flag_version); + const flag_version = try allocator.dupe(u8, field.name); + defer allocator.free(flag_version); std.mem.replaceScalar(u8, flag_version, '_', '-'); try stdout.print("--{s}\n", .{flag_version}); }, else => { - const flag_version = try a.dupe(u8, field.name); - defer a.free(flag_version); + const flag_version = try allocator.dupe(u8, field.name); + defer allocator.free(flag_version); std.mem.replaceScalar(u8, flag_version, '_', '-'); try stdout.print("--{s} {s}\n", .{ flag_version, field.name }); }, @@ -211,10 +220,10 @@ fn strip_optional(T: type) type { return strip_optional(info.optional.child); } -fn free_field(field: anytype) void { +fn free_field(allocator: std.mem.Allocator, field: anytype) void { switch (@typeInfo(@TypeOf(field))) { .pointer => allocator.free(field), - .optional => if (field) |v| free_field(v), + .optional => if (field) |v| free_field(allocator, v), .bool, .int, .float, .@"enum" => {}, else => @compileError("Disallowed struct field type."), } diff --git a/src/main.zig b/src/main.zig index 124d8adfdaf..b48741325e0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -15,7 +15,6 @@ var log_level: std.log.Level = switch (builtin.mode) { .Debug => .debug, else => .warn, }; -var allocator: std.mem.Allocator = undefined; fn logFn( comptime message_level: std.log.Level, @@ -38,7 +37,24 @@ const Args = struct { excluded_langs: ?[]const u8 = null, exclude_private: bool = false, - pub fn deinit(self: @This()) void { + const Self = @This(); + + pub fn init(allocator: std.mem.Allocator) !Self { + return try argparse.parse(allocator, Self, struct { + fn errorCheck(a: Self, stderr: *std.Io.Writer) !bool { + if (a.api_key == null and a.json_input_file == null) { + try stderr.print( + "You must pass either an input file or an API key.\n", + .{}, + ); + return false; + } + return true; + } + }.errorCheck); + } + + pub fn deinit(self: Self, allocator: std.mem.Allocator) void { if (self.api_key) |s| allocator.free(s); if (self.json_input_file) |s| allocator.free(s); if (self.json_output_file) |s| allocator.free(s); @@ -50,21 +66,10 @@ const Args = struct { pub fn main() !void { var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; defer _ = gpa.deinit(); - allocator = gpa.allocator(); - - const args = try argparse.parse(allocator, Args, struct { - fn errorCheck(a: Args, stderr: *std.Io.Writer) !bool { - if (a.api_key == null and a.json_input_file == null) { - try stderr.print( - "You must pass either an input file or an API key.\n", - .{}, - ); - return false; - } - return true; - } - }.errorCheck); - defer args.deinit(); + const allocator = gpa.allocator(); + + const args = try Args.init(allocator); + defer args.deinit(allocator); if (args.silent) { log_level = .err; } else if (args.verbose) { @@ -114,7 +119,7 @@ pub fn main() !void { defer client.deinit(); stats = try Statistics.init(&client, allocator); } else unreachable; - defer stats.deinit(); + defer stats.deinit(allocator); if (args.json_output_file) |path| { std.log.info("Writing raw JSON data to '{s}'", .{path}); diff --git a/src/statistics.zig b/src/statistics.zig index bdffb3d0110..db4f7af94f4 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -10,7 +10,6 @@ commit_contributions: u32 = 0, pr_contributions: u32 = 0, review_contributions: u32 = 0, -var allocator: std.mem.Allocator = undefined; const Statistics = @This(); const Repository = struct { @@ -22,11 +21,11 @@ const Repository = struct { views: u32, private: bool, - pub fn deinit(self: @This()) void { + pub fn deinit(self: @This(), allocator: std.mem.Allocator) void { allocator.free(self.name); if (self.languages) |languages| { for (languages) |language| { - language.deinit(); + language.deinit(allocator); } allocator.free(languages); } @@ -94,25 +93,23 @@ const Language = struct { size: u32, color: ?[]const u8 = null, - pub fn deinit(self: @This()) void { + pub fn deinit(self: @This(), allocator: std.mem.Allocator) void { allocator.free(self.name); if (self.color) |color| allocator.free(color); } }; -pub fn init(client: *HttpClient, a: std.mem.Allocator) !Statistics { - allocator = a; +pub fn init(client: *HttpClient, allocator: std.mem.Allocator) !Statistics { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); - var self: Statistics = try get_repos(&arena, client); - errdefer self.deinit(); + var self: Statistics = try get_repos(allocator, &arena, client); + errdefer self.deinit(allocator); try self.get_lines_changed(&arena, client); return self; } -pub fn initFromJson(a: std.mem.Allocator, s: []const u8) !Statistics { - allocator = a; +pub fn initFromJson(allocator: std.mem.Allocator, s: []const u8) !Statistics { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); @@ -125,9 +122,9 @@ pub fn initFromJson(a: std.mem.Allocator, s: []const u8) !Statistics { return try deepcopy(allocator, parsed); } -pub fn deinit(self: Statistics) void { +pub fn deinit(self: Statistics, allocator: std.mem.Allocator) void { for (self.repositories) |repository| { - repository.deinit(); + repository.deinit(allocator); } allocator.free(self.repositories); allocator.free(self.user); @@ -136,7 +133,7 @@ pub fn deinit(self: Statistics) void { fn get_basic_info( client: *HttpClient, - alloc: std.mem.Allocator, + allocator: std.mem.Allocator, ) !struct { []u32, []const u8, ?[]const u8 } { std.log.info("Getting contribution years...", .{}); const response, const status = try client.graphql( @@ -165,7 +162,7 @@ fn get_basic_info( contributionYears: []u32, }, } } }, - alloc, + allocator, response, .{ .ignore_unknown_fields = true }, )).data.viewer; @@ -177,6 +174,7 @@ fn get_basic_info( } fn get_repos( + allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator, client: *HttpClient, ) !Statistics { @@ -189,7 +187,7 @@ fn get_repos( try .initCapacity(allocator, 32); errdefer { for (repositories.items) |repo| { - repo.deinit(); + repo.deinit(allocator); } repositories.deinit(allocator); } @@ -347,7 +345,7 @@ fn get_repos( } } } - errdefer repository.deinit(); + errdefer repository.deinit(allocator); std.log.info( "Getting views for {s}...", From 00061d2e6835b91725a13a99915eab73c6b8e495 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Tue, 24 Mar 2026 23:49:22 -0400 Subject: [PATCH 56/72] Commit generated files on a separate branch --- .github/workflows/main.yml | 26 +-- README.md | 11 +- generated/languages.svg | 392 ------------------------------------- generated/overview.svg | 113 ----------- 4 files changed, 22 insertions(+), 520 deletions(-) delete mode 100644 generated/languages.svg delete mode 100644 generated/overview.svg diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bfa93b79f7e..1a79327ced1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,36 +11,40 @@ on: permissions: contents: write +defaults: + run: + shell: bash -euxo pipefail {0} + jobs: build: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v3 + - name: Checkout history branch + run: | + git config --global user.name "jstrieb/github-stats" + git config --global user.email "github-stats[bot]@jstrieb.github.io" + git checkout generated || git checkout -b generated + git merge master + - uses: mlugg/setup-zig@v2 with: version: 0.15.2 - # TODO: Cache build - name: Build run: | - echo TODO + zig build --release - name: Generate images run: | - echo TODO + ./zig-out/bin/github_stats env: - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXCLUDED: ${{ secrets.EXCLUDED }} + API_KEY: ${{ secrets.GITHUB_TOKEN }} + EXCLUDED_REPOS: ${{ secrets.EXCLUDED_REPOS }} EXCLUDED_LANGS: ${{ secrets.EXCLUDED_LANGS }} - EXCLUDE_FORKED_REPOS: true - # Commit all changed files to the repository - name: Commit to the repo run: | - git config --global user.name "jstrieb/github-stats" - git config --global user.email "github-stats[bot]@jstrieb.github.io" git add . # Force the build to succeed, even if no files were changed git commit -m 'Update generated files' || true diff --git a/README.md b/README.md index 865513595b7..63955777653 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,15 @@ + + Generate visualizations of GitHub user and repository statistics with GitHub Actions. Visualizations can include data from private repositories, and from diff --git a/generated/languages.svg b/generated/languages.svg deleted file mode 100644 index 02d63624f6b..00000000000 --- a/generated/languages.svg +++ /dev/null @@ -1,392 +0,0 @@ - - - - - - -
    - -

    Languages Used (By File Size)

    - -
    - - - -
    - -
      - - -
    • - -Python -29.30% -
    • - - -
    • - -C -17.71% -
    • - - -
    • - -Zig -11.03% -
    • - - -
    • - -JavaScript -9.41% -
    • - - -
    • - -Svelte -7.45% -
    • - - -
    • - -Standard ML -6.67% -
    • - - -
    • - -Shell -5.34% -
    • - - -
    • - -Go -2.41% -
    • - - -
    • - -SMT -2.05% -
    • - - -
    • - -TeX -1.88% -
    • - - -
    • - -CSS -1.68% -
    • - - -
    • - -Makefile -1.41% -
    • - - -
    • - -Java -1.31% -
    • - - -
    • - -C++ -0.66% -
    • - - -
    • - -OpenSCAD -0.35% -
    • - - -
    • - -Vim Script -0.19% -
    • - - -
    • - -TypeScript -0.19% -
    • - - -
    • - -Assembly -0.17% -
    • - - -
    • - -GDB -0.16% -
    • - - -
    • - -Nix -0.16% -
    • - - -
    • - -PHP -0.13% -
    • - - -
    • - -Tree-sitter Query -0.12% -
    • - - -
    • - -Just -0.09% -
    • - - -
    • - -Dockerfile -0.08% -
    • - - -
    • - -CMake -0.03% -
    • - - -
    • - -sed -0.01% -
    • - - -
    • - -Batchfile -0.01% -
    • - - - -
    - -
    -
    -
    -
    -
    diff --git a/generated/overview.svg b/generated/overview.svg deleted file mode 100644 index e2b0b6baf34..00000000000 --- a/generated/overview.svg +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - -
    - - - - - - - - - - - - - - - - - - - - -
    Jacob Strieb's GitHub Statistics
    Stars7,515
    Forks1,160
    All-time contributions4,543
    Lines of code changed2,777,663
    Repository views (past two weeks)1,568
    Repositories with contributions128
    - -
    -
    -
    -
    -
    From 51776b8a1a448bbf5c77544d01daa28e5c3654ee Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 25 Mar 2026 16:18:31 -0400 Subject: [PATCH 57/72] Output overview SVG --- src/main.zig | 55 +++++++++++++++++----- {templates => src/templates}/languages.svg | 0 {templates => src/templates}/overview.svg | 0 3 files changed, 42 insertions(+), 13 deletions(-) rename {templates => src/templates}/languages.svg (100%) rename {templates => src/templates}/overview.svg (100%) diff --git a/src/main.zig b/src/main.zig index b48741325e0..fb0735346ba 100644 --- a/src/main.zig +++ b/src/main.zig @@ -36,6 +36,8 @@ const Args = struct { excluded_repos: ?[]const u8 = null, excluded_langs: ?[]const u8 = null, exclude_private: bool = false, + overview_output_file: ?[]const u8 = null, + languages_output_file: ?[]const u8 = null, const Self = @This(); @@ -60,6 +62,8 @@ const Args = struct { if (self.json_output_file) |s| allocator.free(s); if (self.excluded_repos) |s| allocator.free(s); if (self.excluded_langs) |s| allocator.free(s); + if (self.overview_output_file) |s| allocator.free(s); + if (self.languages_output_file) |s| allocator.free(s); } }; @@ -182,21 +186,46 @@ pub fn main() !void { }; } - inline for (@typeInfo(@TypeOf(aggregate_stats)).@"struct".fields) |field| { - if (!std.mem.eql(u8, field.name, "languages")) { - std.debug.print("{s}: {any}\n", .{ - field.name, - @field(aggregate_stats, field.name), - }); + { + const template: []const u8 = @embedFile("templates/overview.svg"); + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + var out_data = template; + inline for ( + @typeInfo(@TypeOf(aggregate_stats)).@"struct".fields, + ) |field| { + switch (@typeInfo(field.type)) { + .int => { + out_data = try std.mem.replaceOwned( + u8, + a, + out_data, + "{{ " ++ field.name ++ " }}", + try std.fmt.allocPrint( + a, + "{d}", + .{@field(aggregate_stats, field.name)}, + ), + ); + }, + else => {}, + } } - } - std.debug.print("\n", .{}); - for ( - aggregate_stats.languages.keys(), - aggregate_stats.languages.values(), - ) |key, value| { - std.debug.print("{s}: {any}\n", .{ key, value }); + const path = args.overview_output_file orelse "overview.svg"; + std.log.info("Writing overview image data to '{s}'", .{path}); + const out = + if (std.mem.eql(u8, path, "-")) + std.fs.File.stdout() + else + try std.fs.cwd().createFile(path, .{}); + defer out.close(); + var write_buffer: [64 * 1024]u8 = undefined; + var writer = out.writer(&write_buffer); + + try writer.interface.writeAll(out_data); + try writer.interface.flush(); } } diff --git a/templates/languages.svg b/src/templates/languages.svg similarity index 100% rename from templates/languages.svg rename to src/templates/languages.svg diff --git a/templates/overview.svg b/src/templates/overview.svg similarity index 100% rename from templates/overview.svg rename to src/templates/overview.svg From ccd8ab4f8590d8aeb5f0e3c5d242bcb64f2cab71 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Wed, 25 Mar 2026 16:43:12 -0400 Subject: [PATCH 58/72] Print large numbers with commas --- src/main.zig | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/main.zig b/src/main.zig index fb0735346ba..6446d302f0e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -202,10 +202,9 @@ pub fn main() !void { a, out_data, "{{ " ++ field.name ++ " }}", - try std.fmt.allocPrint( + try decimalToString( a, - "{d}", - .{@field(aggregate_stats, field.name)}, + @field(aggregate_stats, field.name), ), ); }, @@ -232,3 +231,33 @@ pub fn main() !void { test { std.testing.refAllDecls(@This()); } + +fn decimalToString(allocator: std.mem.Allocator, n: anytype) ![]const u8 { + const info = @typeInfo(@TypeOf(n)); + if (info != .int or info.int.signedness != .unsigned) { + @compileError("Only implemented for unsigned integer numbers."); + } + if (n == 0) { + return try allocator.dupe(u8, "0"); + } + const s = try std.fmt.allocPrint(allocator, "{d}", .{n}); + defer allocator.free(s); + const digits = s.len; + const commas = (digits - 1) / 3; + const result = try allocator.alloc(u8, digits + commas); + var i: usize = result.len - 1; + var j: usize = s.len - 1; + while (true) { + if ((result.len - i) % 4 == 0) { + result[i] = ','; + i -= 1; + } + result[i] = s[j]; + if (i == 0 and j == 0) { + break; + } else if (i > 0 and j > 0) {} else unreachable; + i -= 1; + j -= 1; + } + return result; +} From 2576f1284114477844e4d261c7b2567965274821 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 28 Mar 2026 16:28:52 -0400 Subject: [PATCH 59/72] Build languages SVG --- src/main.zig | 118 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 5 deletions(-) diff --git a/src/main.zig b/src/main.zig index 6446d302f0e..ee8f8a979ce 100644 --- a/src/main.zig +++ b/src/main.zig @@ -108,6 +108,7 @@ pub fn main() !void { std.fs.File.stdin() else try std.fs.cwd().openFile(path, .{}); + // TODO: Don't close stdin defer in.close(); var read_buffer: [64 * 1024]u8 = undefined; var reader = in.reader(&read_buffer); @@ -132,6 +133,7 @@ pub fn main() !void { std.fs.File.stdout() else try std.fs.cwd().createFile(path, .{}); + // TODO: Don't close stdout defer out.close(); var write_buffer: [64 * 1024]u8 = undefined; var writer = out.writer(&write_buffer); @@ -150,7 +152,10 @@ pub fn main() !void { var aggregate_stats: struct { languages: std.StringArrayHashMap(u64), + language_colors: std.StringArrayHashMap([]const u8), contributions: usize, + name: []const u8, + languages_total: usize = 0, stars: usize = 0, forks: usize = 0, lines_changed: usize = 0, @@ -163,8 +168,11 @@ pub fn main() !void { stats.pr_contributions + stats.review_contributions, .languages = .init(allocator), + .language_colors = .init(allocator), + .name = stats.name, }; defer aggregate_stats.languages.deinit(); + defer aggregate_stats.language_colors.deinit(); for (stats.repositories) |repository| { if (glob.matchAny(excluded_repos orelse &.{}, repository.name) or (args.exclude_private and repository.private)) @@ -177,20 +185,33 @@ pub fn main() !void { aggregate_stats.views += repository.views; aggregate_stats.repos += 1; if (repository.languages) |langs| for (langs) |language| { + if (language.color) |color| { + try aggregate_stats.language_colors.put(language.name, color); + } if (glob.matchAny(excluded_langs orelse &.{}, language.name)) { continue; } var total = aggregate_stats.languages.get(language.name) orelse 0; total += language.size; try aggregate_stats.languages.put(language.name, total); + aggregate_stats.languages_total += language.size; }; } + aggregate_stats.languages.sort(struct { + values: @TypeOf(aggregate_stats.languages.values()), + pub fn lessThan(self: @This(), a: usize, b: usize) bool { + // Sort in reverse order + return self.values[a] >= self.values[b]; + } + }{ .values = aggregate_stats.languages.values() }); { const template: []const u8 = @embedFile("templates/overview.svg"); + var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator(); + var out_data = template; inline for ( @typeInfo(@TypeOf(aggregate_stats)).@"struct".fields, @@ -208,7 +229,17 @@ pub fn main() !void { ), ); }, - else => {}, + .pointer => { + out_data = try std.mem.replaceOwned( + u8, + a, + out_data, + "{{ " ++ field.name ++ " }}", + @field(aggregate_stats, field.name), + ); + }, + .@"struct" => {}, + else => comptime unreachable, } } @@ -219,6 +250,83 @@ pub fn main() !void { std.fs.File.stdout() else try std.fs.cwd().createFile(path, .{}); + // TODO: Don't close stdout + defer out.close(); + var write_buffer: [64 * 1024]u8 = undefined; + var writer = out.writer(&write_buffer); + + try writer.interface.writeAll(out_data); + try writer.interface.flush(); + } + + { + const template: []const u8 = @embedFile("templates/languages.svg"); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const progress = + try a.alloc([]const u8, aggregate_stats.languages.count()); + const lang_list = + try a.alloc([]const u8, aggregate_stats.languages.count()); + for ( + aggregate_stats.languages.keys(), + aggregate_stats.languages.values(), + progress, + lang_list, + 0.., + ) |language, count, *progress_s, *lang_s, i| { + const color = aggregate_stats.language_colors.get(language); + const percent = + 100 * if (aggregate_stats.languages_total == 0) + 0.0 + else + @as(f64, @floatFromInt(count)) / + @as(f64, @floatFromInt(aggregate_stats.languages_total)); + progress_s.* = try std.fmt.allocPrint(a, + \\ + , .{ color orelse "#000", percent }); + lang_s.* = try std.fmt.allocPrint(a, + \\
  • + \\ + \\ {s} + \\ {d:.2}% + \\
  • + \\ + , .{ (i + 1) * 150, color orelse "#000", language, percent }); + } + const out_data = + try std.mem.replaceOwned(u8, a, try std.mem.replaceOwned( + u8, + a, + template, + "{{ lang_list }}", + try std.mem.concat(a, u8, lang_list), + ), "{{ progress }}", try std.mem.concat(a, u8, progress)); + + const path = args.overview_output_file orelse "languages.svg"; + std.log.info("Writing languages image data to '{s}'", .{path}); + const out = + if (std.mem.eql(u8, path, "-")) + std.fs.File.stdout() + else + try std.fs.cwd().createFile(path, .{}); + // TODO: Don't close stdout defer out.close(); var write_buffer: [64 * 1024]u8 = undefined; var writer = out.writer(&write_buffer); @@ -235,16 +343,16 @@ test { fn decimalToString(allocator: std.mem.Allocator, n: anytype) ![]const u8 { const info = @typeInfo(@TypeOf(n)); if (info != .int or info.int.signedness != .unsigned) { - @compileError("Only implemented for unsigned integer numbers."); - } - if (n == 0) { - return try allocator.dupe(u8, "0"); + @compileError("Only implemented for unsigned integers."); } + const s = try std.fmt.allocPrint(allocator, "{d}", .{n}); defer allocator.free(s); const digits = s.len; const commas = (digits - 1) / 3; const result = try allocator.alloc(u8, digits + commas); + errdefer comptime unreachable; + var i: usize = result.len - 1; var j: usize = s.len - 1; while (true) { From 5a98fa75ed41b81f2fdf5df951d0151f7a5784a2 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 28 Mar 2026 16:53:13 -0400 Subject: [PATCH 60/72] Break templating into separate functions --- src/main.zig | 179 +++++++++++++++++++++++++-------------------------- 1 file changed, 89 insertions(+), 90 deletions(-) diff --git a/src/main.zig b/src/main.zig index ee8f8a979ce..6fdbb43814d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -67,6 +67,91 @@ const Args = struct { } }; +fn overview(a: std.mem.Allocator, stats: anytype) ![]const u8 { + const template: []const u8 = @embedFile("templates/overview.svg"); + var out_data = template; + inline for ( + @typeInfo(@TypeOf(stats)).@"struct".fields, + ) |field| { + switch (@typeInfo(field.type)) { + .int => { + out_data = try std.mem.replaceOwned( + u8, + a, + out_data, + "{{ " ++ field.name ++ " }}", + try decimalToString(a, @field(stats, field.name)), + ); + }, + .pointer => { + out_data = try std.mem.replaceOwned( + u8, + a, + out_data, + "{{ " ++ field.name ++ " }}", + @field(stats, field.name), + ); + }, + .@"struct" => {}, + else => comptime unreachable, + } + } + return out_data; +} + +fn languages(a: std.mem.Allocator, stats: anytype) ![]const u8 { + const template: []const u8 = @embedFile("templates/languages.svg"); + const progress = try a.alloc([]const u8, stats.languages.count()); + const lang_list = try a.alloc([]const u8, stats.languages.count()); + for ( + stats.languages.keys(), + stats.languages.values(), + progress, + lang_list, + 0.., + ) |language, count, *progress_s, *lang_s, i| { + const color = stats.language_colors.get(language); + const percent = + 100 * if (stats.languages_total == 0) + 0.0 + else + @as(f64, @floatFromInt(count)) / + @as(f64, @floatFromInt(stats.languages_total)); + progress_s.* = try std.fmt.allocPrint(a, + \\ + , .{ color orelse "#000", percent }); + lang_s.* = try std.fmt.allocPrint(a, + \\
  • + \\ + \\ {s} + \\ {d:.2}% + \\
  • + \\ + , .{ (i + 1) * 150, color orelse "#000", language, percent }); + } + return try std.mem.replaceOwned(u8, a, try std.mem.replaceOwned( + u8, + a, + template, + "{{ lang_list }}", + try std.mem.concat(a, u8, lang_list), + ), "{{ progress }}", try std.mem.concat(a, u8, progress)); +} + pub fn main() !void { var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; defer _ = gpa.deinit(); @@ -206,44 +291,13 @@ pub fn main() !void { }{ .values = aggregate_stats.languages.values() }); { - const template: []const u8 = @embedFile("templates/overview.svg"); - var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator(); - var out_data = template; - inline for ( - @typeInfo(@TypeOf(aggregate_stats)).@"struct".fields, - ) |field| { - switch (@typeInfo(field.type)) { - .int => { - out_data = try std.mem.replaceOwned( - u8, - a, - out_data, - "{{ " ++ field.name ++ " }}", - try decimalToString( - a, - @field(aggregate_stats, field.name), - ), - ); - }, - .pointer => { - out_data = try std.mem.replaceOwned( - u8, - a, - out_data, - "{{ " ++ field.name ++ " }}", - @field(aggregate_stats, field.name), - ); - }, - .@"struct" => {}, - else => comptime unreachable, - } - } - + const out_data = try overview(a, aggregate_stats); const path = args.overview_output_file orelse "overview.svg"; + std.log.info("Writing overview image data to '{s}'", .{path}); const out = if (std.mem.eql(u8, path, "-")) @@ -254,72 +308,18 @@ pub fn main() !void { defer out.close(); var write_buffer: [64 * 1024]u8 = undefined; var writer = out.writer(&write_buffer); - try writer.interface.writeAll(out_data); try writer.interface.flush(); } { - const template: []const u8 = @embedFile("templates/languages.svg"); - var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const a = arena.allocator(); - const progress = - try a.alloc([]const u8, aggregate_stats.languages.count()); - const lang_list = - try a.alloc([]const u8, aggregate_stats.languages.count()); - for ( - aggregate_stats.languages.keys(), - aggregate_stats.languages.values(), - progress, - lang_list, - 0.., - ) |language, count, *progress_s, *lang_s, i| { - const color = aggregate_stats.language_colors.get(language); - const percent = - 100 * if (aggregate_stats.languages_total == 0) - 0.0 - else - @as(f64, @floatFromInt(count)) / - @as(f64, @floatFromInt(aggregate_stats.languages_total)); - progress_s.* = try std.fmt.allocPrint(a, - \\ - , .{ color orelse "#000", percent }); - lang_s.* = try std.fmt.allocPrint(a, - \\
  • - \\ - \\ {s} - \\ {d:.2}% - \\
  • - \\ - , .{ (i + 1) * 150, color orelse "#000", language, percent }); - } - const out_data = - try std.mem.replaceOwned(u8, a, try std.mem.replaceOwned( - u8, - a, - template, - "{{ lang_list }}", - try std.mem.concat(a, u8, lang_list), - ), "{{ progress }}", try std.mem.concat(a, u8, progress)); - + const out_data = try languages(a, aggregate_stats); const path = args.overview_output_file orelse "languages.svg"; + std.log.info("Writing languages image data to '{s}'", .{path}); const out = if (std.mem.eql(u8, path, "-")) @@ -330,7 +330,6 @@ pub fn main() !void { defer out.close(); var write_buffer: [64 * 1024]u8 = undefined; var writer = out.writer(&write_buffer); - try writer.interface.writeAll(out_data); try writer.interface.flush(); } From 5e0c9661ec5f96bb5e93bcfb915339e881d70423 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 28 Mar 2026 17:10:32 -0400 Subject: [PATCH 61/72] Clean up main function --- src/main.zig | 104 ++++++++++++++---------------------- src/templates/languages.svg | 2 +- 2 files changed, 42 insertions(+), 64 deletions(-) diff --git a/src/main.zig b/src/main.zig index 6fdbb43814d..8f42129f086 100644 --- a/src/main.zig +++ b/src/main.zig @@ -187,20 +187,7 @@ pub fn main() !void { var stats: Statistics = undefined; if (args.json_input_file) |path| { - std.log.info("Reading statistics from '{s}'", .{path}); - const in = - if (std.mem.eql(u8, path, "-")) - std.fs.File.stdin() - else - try std.fs.cwd().openFile(path, .{}); - // TODO: Don't close stdin - defer in.close(); - var read_buffer: [64 * 1024]u8 = undefined; - var reader = in.reader(&read_buffer); - // TODO: Create a scanner from the reader instead of reading the whole - // file into memory - const data = - try (&reader.interface).allocRemaining(allocator, .unlimited); + const data = try readFile(allocator, path); defer allocator.free(data); stats = try Statistics.initFromJson(allocator, data); } else if (args.api_key) |api_key| { @@ -212,27 +199,16 @@ pub fn main() !void { defer stats.deinit(allocator); if (args.json_output_file) |path| { - std.log.info("Writing raw JSON data to '{s}'", .{path}); - const out = - if (std.mem.eql(u8, path, "-")) - std.fs.File.stdout() - else - try std.fs.cwd().createFile(path, .{}); - // TODO: Don't close stdout - defer out.close(); - var write_buffer: [64 * 1024]u8 = undefined; - var writer = out.writer(&write_buffer); - var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); - try writer.interface.writeAll( + try writeFile( + path, try std.json.Stringify.valueAlloc( arena.allocator(), stats, .{ .whitespace = .indent_2 }, ), ); - try writer.interface.flush(); } var aggregate_stats: struct { @@ -295,43 +271,15 @@ pub fn main() !void { defer arena.deinit(); const a = arena.allocator(); - const out_data = try overview(a, aggregate_stats); - const path = args.overview_output_file orelse "overview.svg"; - - std.log.info("Writing overview image data to '{s}'", .{path}); - const out = - if (std.mem.eql(u8, path, "-")) - std.fs.File.stdout() - else - try std.fs.cwd().createFile(path, .{}); - // TODO: Don't close stdout - defer out.close(); - var write_buffer: [64 * 1024]u8 = undefined; - var writer = out.writer(&write_buffer); - try writer.interface.writeAll(out_data); - try writer.interface.flush(); - } - - { - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - const a = arena.allocator(); - - const out_data = try languages(a, aggregate_stats); - const path = args.overview_output_file orelse "languages.svg"; + try writeFile( + args.overview_output_file orelse "overview.svg", + try overview(a, aggregate_stats), + ); - std.log.info("Writing languages image data to '{s}'", .{path}); - const out = - if (std.mem.eql(u8, path, "-")) - std.fs.File.stdout() - else - try std.fs.cwd().createFile(path, .{}); - // TODO: Don't close stdout - defer out.close(); - var write_buffer: [64 * 1024]u8 = undefined; - var writer = out.writer(&write_buffer); - try writer.interface.writeAll(out_data); - try writer.interface.flush(); + try writeFile( + args.languages_output_file orelse "languages.svg", + try languages(a, aggregate_stats), + ); } } @@ -339,6 +287,36 @@ test { std.testing.refAllDecls(@This()); } +fn readFile(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { + std.log.info("Reading data from '{s}'", .{path}); + const in = + if (std.mem.eql(u8, path, "-")) + std.fs.File.stdin() + else + try std.fs.cwd().openFile(path, .{}); + defer if (!std.mem.eql(u8, path, "-")) in.close(); + var read_buffer: [64 * 1024]u8 = undefined; + var reader = in.reader(&read_buffer); + return try (&reader.interface).allocRemaining(allocator, .unlimited); +} + +fn writeFile( + path: []const u8, + data: []const u8, +) !void { + std.log.info("Writing data to '{s}'", .{path}); + const out = + if (std.mem.eql(u8, path, "-")) + std.fs.File.stdout() + else + try std.fs.cwd().createFile(path, .{}); + defer if (!std.mem.eql(u8, path, "-")) out.close(); + var write_buffer: [64 * 1024]u8 = undefined; + var writer = out.writer(&write_buffer); + try writer.interface.writeAll(data); + try writer.interface.flush(); +} + fn decimalToString(allocator: std.mem.Allocator, n: anytype) ![]const u8 { const info = @typeInfo(@TypeOf(n)); if (info != .int or info.int.signedness != .unsigned) { diff --git a/src/templates/languages.svg b/src/templates/languages.svg index a3754df18be..2d3aded586d 100644 --- a/src/templates/languages.svg +++ b/src/templates/languages.svg @@ -51,7 +51,7 @@ ul { li { display: inline-flex; font-size: 12px; - margin-right: 2ch; + margin-right: 1ch; align-items: center; flex-wrap: nowrap; transform: translateX(-500%); From 1c4665421c5dc0fe9f20ba1864df1613568d19ae Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 28 Mar 2026 17:34:14 -0400 Subject: [PATCH 62/72] Small fixes and tweaks --- .github/workflows/main.yml | 1 + build.zig.zon | 1 - src/main.zig | 23 +++++++++++------------ src/statistics.zig | 35 ++++++++++++++++++++++++++--------- 4 files changed, 38 insertions(+), 22 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1a79327ced1..51b9f6f578a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,6 +24,7 @@ jobs: run: | git config --global user.name "jstrieb/github-stats" git config --global user.email "github-stats[bot]@jstrieb.github.io" + # Push generated files to the generated branch git checkout generated || git checkout -b generated git merge master diff --git a/build.zig.zon b/build.zig.zon index c7bf1934a9c..4b090bd8871 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -10,6 +10,5 @@ "src", "LICENSE", "README.md", - "templates", }, } diff --git a/src/main.zig b/src/main.zig index 8f42129f086..a6bbfa6d787 100644 --- a/src/main.zig +++ b/src/main.zig @@ -67,12 +67,11 @@ const Args = struct { } }; -fn overview(a: std.mem.Allocator, stats: anytype) ![]const u8 { +fn overview(arena: *std.heap.ArenaAllocator, stats: anytype) ![]const u8 { + const a = arena.allocator(); const template: []const u8 = @embedFile("templates/overview.svg"); var out_data = template; - inline for ( - @typeInfo(@TypeOf(stats)).@"struct".fields, - ) |field| { + inline for (@typeInfo(@TypeOf(stats)).@"struct".fields) |field| { switch (@typeInfo(field.type)) { .int => { out_data = try std.mem.replaceOwned( @@ -99,7 +98,8 @@ fn overview(a: std.mem.Allocator, stats: anytype) ![]const u8 { return out_data; } -fn languages(a: std.mem.Allocator, stats: anytype) ![]const u8 { +fn languages(arena: *std.heap.ArenaAllocator, stats: anytype) ![]const u8 { + const a = arena.allocator(); const template: []const u8 = @embedFile("templates/languages.svg"); const progress = try a.alloc([]const u8, stats.languages.count()); const lang_list = try a.alloc([]const u8, stats.languages.count()); @@ -246,12 +246,12 @@ pub fn main() !void { aggregate_stats.views += repository.views; aggregate_stats.repos += 1; if (repository.languages) |langs| for (langs) |language| { - if (language.color) |color| { - try aggregate_stats.language_colors.put(language.name, color); - } if (glob.matchAny(excluded_langs orelse &.{}, language.name)) { continue; } + if (language.color) |color| { + try aggregate_stats.language_colors.put(language.name, color); + } var total = aggregate_stats.languages.get(language.name) orelse 0; total += language.size; try aggregate_stats.languages.put(language.name, total); @@ -262,23 +262,22 @@ pub fn main() !void { values: @TypeOf(aggregate_stats.languages.values()), pub fn lessThan(self: @This(), a: usize, b: usize) bool { // Sort in reverse order - return self.values[a] >= self.values[b]; + return self.values[a] > self.values[b]; } }{ .values = aggregate_stats.languages.values() }); { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); - const a = arena.allocator(); try writeFile( args.overview_output_file orelse "overview.svg", - try overview(a, aggregate_stats), + try overview(&arena, aggregate_stats), ); try writeFile( args.languages_output_file orelse "languages.svg", - try languages(a, aggregate_stats), + try languages(&arena, aggregate_stats), ); } } diff --git a/src/statistics.zig b/src/statistics.zig index db4f7af94f4..d51782c05cf 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -322,30 +322,40 @@ fn get_repos( .views = 0, .lines_changed = 0, }; - + errdefer repository.deinit(allocator); if (raw_repo.languages) |repo_languages| { - // TODO: Properly free partially initialized memory when any try - // fails in this block if (repo_languages.edges) |raw_languages| { repository.languages = try allocator.alloc( Language, raw_languages.len, ); + errdefer { + allocator.free(repository.languages.?); + repository.languages = null; + } for ( raw_languages, repository.languages.?, - ) |raw, *language| { + 0.., + ) |raw, *language, i| { + errdefer { + for (0..i, repository.languages.?) |_, l| { + allocator.free(l.name); + if (l.color) |c| allocator.free(c); + } + } language.* = .{ .name = try allocator.dupe(u8, raw.node.name), .size = raw.size, }; + errdefer allocator.free(language.name); if (raw.node.color) |color| { language.color = try allocator.dupe(u8, color); } + errdefer if (language.color) |c| allocator.free(c); } } } - errdefer repository.deinit(allocator); std.log.info( "Getting views for {s}...", @@ -378,13 +388,19 @@ fn get_repos( _ = try repository.get_lines_changed(arena, client, user); - try repositories.append(allocator, repository); try seen.put(raw_repo.nameWithOwner, true); + try repositories.append(allocator, repository); } } - const list = try repositories.toOwnedSlice(allocator); - std.sort.pdq(Repository, list, {}, struct { + result.repositories = try repositories.toOwnedSlice(allocator); + errdefer { + for (result.repositories) |repository| { + repository.deinit(allocator); + } + allocator.free(result.repositories); + } + std.sort.pdq(Repository, result.repositories, {}, struct { pub fn lessThanFn(_: void, lhs: Repository, rhs: Repository) bool { if (rhs.views == lhs.views) { return rhs.stars + rhs.forks < lhs.stars + lhs.forks; @@ -397,7 +413,6 @@ fn get_repos( errdefer allocator.free(result.user); result.name = try allocator.dupe(u8, name orelse user); errdefer allocator.free(result.name); - result.repositories = list; return result; } @@ -460,11 +475,13 @@ fn get_lines_changed( } } +// May not correctly free memory if there are errors during copying fn deepcopy(a: std.mem.Allocator, o: anytype) !@TypeOf(o) { return switch (@typeInfo(@TypeOf(o))) { .pointer => |p| switch (p.size) { .slice => v: { const result = try a.dupe(p.child, o); + errdefer a.free(result); for (o, result) |src, *dest| { dest.* = try deepcopy(a, src); } From f711476867c41cb0140e34a5d7ea3906af771533 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 28 Mar 2026 23:18:50 -0400 Subject: [PATCH 63/72] Subdivide time interval when too many repos --- src/statistics.zig | 437 ++++++++++++++++++++++++++------------------- 1 file changed, 249 insertions(+), 188 deletions(-) diff --git a/src/statistics.zig b/src/statistics.zig index d51782c05cf..0226af4c538 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -173,6 +173,244 @@ fn get_basic_info( }; } +fn get_repos_by_year( + allocator: std.mem.Allocator, + arena: *std.heap.ArenaAllocator, + client: *HttpClient, + user: []const u8, + result: *Statistics, + seen: *std.StringHashMap(bool), + repositories: *std.ArrayList(Repository), + year: usize, + start_month: usize, + months: usize, +) !void { + std.log.info( + "Getting {d} month{s} of data starting from {d}/{d}...", + .{ months, if (months != 1) "s" else "", start_month + 1, year }, + ); + var response, var status = try client.graphql( + \\query ($from: DateTime, $to: DateTime) { + \\ viewer { + \\ contributionsCollection(from: $from, to: $to) { + \\ totalRepositoryContributions + \\ totalIssueContributions + \\ totalCommitContributions + \\ totalPullRequestContributions + \\ totalPullRequestReviewContributions + \\ commitContributionsByRepository(maxRepositories: 100) { + \\ repository { + \\ nameWithOwner + \\ stargazerCount + \\ forkCount + \\ isPrivate + \\ languages( + \\ first: 100, + \\ orderBy: { direction: DESC, field: SIZE } + \\ ) { + \\ edges { + \\ size + \\ node { + \\ name + \\ color + \\ } + \\ } + \\ } + \\ } + \\ } + \\ } + \\ } + \\} + , + .{ + .from = try std.fmt.allocPrint( + arena.allocator(), + "{d}-{d:02}-01T00:00:00Z", + .{ year, start_month + 1 }, + ), + .to = try std.fmt.allocPrint( + arena.allocator(), + "{d}-{d:02}-01T00:00:00Z", + .{ + year + (start_month + months) / 12, + (start_month + months) % 12 + 1, + }, + ), + }, + ); + if (status != .ok) { + std.log.err( + "Failed to get data from {d} ({?s})", + .{ year, status.phrase() }, + ); + return error.RequestFailed; + } + const viewer = (std.json.parseFromSliceLeaky( + struct { data: struct { viewer: struct { + contributionsCollection: struct { + totalRepositoryContributions: u32, + totalIssueContributions: u32, + totalCommitContributions: u32, + totalPullRequestContributions: u32, + totalPullRequestReviewContributions: u32, + commitContributionsByRepository: []struct { + repository: struct { + nameWithOwner: []const u8, + stargazerCount: u32, + forkCount: u32, + isPrivate: bool, + languages: ?struct { + edges: ?[]struct { + size: u32, + node: struct { + name: []const u8, + color: ?[]const u8, + }, + }, + }, + }, + }, + }, + } } }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + ) catch |err| { + std.debug.print("{s}\n", .{response}); + return err; + }).data.viewer; + + const stats = viewer.contributionsCollection; + std.log.info( + "Parsed {d} total repositories from {d}", + .{ stats.commitContributionsByRepository.len, year }, + ); + + const limit = 100; + if (stats.commitContributionsByRepository.len >= limit) { + for (&[_]usize{ 2, 3 }) |factor| { + if (months % factor == 0) { + for (0..factor) |i| { + try get_repos_by_year( + allocator, + arena, + client, + user, + result, + seen, + repositories, + year, + start_month + (months / factor) * i, + months / factor, + ); + } + return; + } + } else { + std.log.warn( + "More than {d} repos returned for {d}/{d}. " ++ + "Some data may be omitted due to GitHub API limitations.", + .{ limit, start_month + 1, year }, + ); + } + } + + result.repo_contributions += stats.totalRepositoryContributions; + result.issue_contributions += stats.totalIssueContributions; + result.commit_contributions += stats.totalCommitContributions; + result.pr_contributions += stats.totalPullRequestContributions; + result.review_contributions += + stats.totalPullRequestReviewContributions; + + for (stats.commitContributionsByRepository) |x| { + const raw_repo = x.repository; + if (seen.get(raw_repo.nameWithOwner) orelse false) { + std.log.debug( + "Skipping {s} (seen)", + .{raw_repo.nameWithOwner}, + ); + continue; + } + var repository = Repository{ + .name = try allocator.dupe(u8, raw_repo.nameWithOwner), + .stars = raw_repo.stargazerCount, + .forks = raw_repo.forkCount, + .private = raw_repo.isPrivate, + .languages = null, + .views = 0, + .lines_changed = 0, + }; + errdefer repository.deinit(allocator); + if (raw_repo.languages) |repo_languages| { + if (repo_languages.edges) |raw_languages| { + repository.languages = try allocator.alloc( + Language, + raw_languages.len, + ); + errdefer { + allocator.free(repository.languages.?); + repository.languages = null; + } + for ( + raw_languages, + repository.languages.?, + 0.., + ) |raw, *language, i| { + errdefer { + for (0..i, repository.languages.?) |_, l| { + allocator.free(l.name); + if (l.color) |c| allocator.free(c); + } + } + language.* = .{ + .name = try allocator.dupe(u8, raw.node.name), + .size = raw.size, + }; + errdefer allocator.free(language.name); + if (raw.node.color) |color| { + language.color = try allocator.dupe(u8, color); + } + errdefer if (language.color) |c| allocator.free(c); + } + } + } + + std.log.info( + "Getting views for {s}...", + .{raw_repo.nameWithOwner}, + ); + response, status = try client.rest( + try std.mem.concat( + arena.allocator(), + u8, + &.{ + "https://api.github.com/repos/", + raw_repo.nameWithOwner, + "/traffic/views", + }, + ), + ); + if (status == .ok) { + repository.views = (try std.json.parseFromSliceLeaky( + struct { count: u32 }, + arena.allocator(), + response, + .{ .ignore_unknown_fields = true }, + )).count; + } else { + std.log.info( + "Failed to get views for {s} ({?s})", + .{ raw_repo.nameWithOwner, status.phrase() }, + ); + } + + _ = try repository.get_lines_changed(arena, client, user); + + try seen.put(raw_repo.nameWithOwner, true); + try repositories.append(allocator, repository); + } +} + fn get_repos( allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator, @@ -202,195 +440,18 @@ fn get_repos( std.log.info("Getting data for user {s}...", .{user}); } for (years) |year| { - std.log.info("Getting data from {d}...", .{year}); - var response, var status = try client.graphql( - \\query ($from: DateTime, $to: DateTime) { - \\ viewer { - \\ contributionsCollection(from: $from, to: $to) { - \\ totalRepositoryContributions - \\ totalIssueContributions - \\ totalCommitContributions - \\ totalPullRequestContributions - \\ totalPullRequestReviewContributions - \\ commitContributionsByRepository(maxRepositories: 100) { - \\ repository { - \\ nameWithOwner - \\ stargazerCount - \\ forkCount - \\ isPrivate - \\ languages( - \\ first: 100, - \\ orderBy: { direction: DESC, field: SIZE } - \\ ) { - \\ edges { - \\ size - \\ node { - \\ name - \\ color - \\ } - \\ } - \\ } - \\ } - \\ } - \\ } - \\ } - \\} - , - .{ - .from = try std.fmt.allocPrint( - arena.allocator(), - "{d}-01-01T00:00:00Z", - .{year}, - ), - .to = try std.fmt.allocPrint( - arena.allocator(), - "{d}-01-01T00:00:00Z", - .{year + 1}, - ), - }, - ); - if (status != .ok) { - std.log.err( - "Failed to get data from {d} ({?s})", - .{ year, status.phrase() }, - ); - return error.RequestFailed; - } - const viewer = (try std.json.parseFromSliceLeaky( - struct { data: struct { viewer: struct { - contributionsCollection: struct { - totalRepositoryContributions: u32, - totalIssueContributions: u32, - totalCommitContributions: u32, - totalPullRequestContributions: u32, - totalPullRequestReviewContributions: u32, - commitContributionsByRepository: []struct { - repository: struct { - nameWithOwner: []const u8, - stargazerCount: u32, - forkCount: u32, - isPrivate: bool, - languages: ?struct { - edges: ?[]struct { - size: u32, - node: struct { - name: []const u8, - color: ?[]const u8, - }, - }, - }, - }, - }, - }, - } } }, - arena.allocator(), - response, - .{ .ignore_unknown_fields = true }, - )).data.viewer; - - const stats = viewer.contributionsCollection; - std.log.info( - "Parsed {d} total repositories from {d}", - .{ stats.commitContributionsByRepository.len, year }, + try get_repos_by_year( + allocator, + arena, + client, + user, + &result, + &seen, + &repositories, + year, + 0, + 12, ); - - result.repo_contributions += stats.totalRepositoryContributions; - result.issue_contributions += stats.totalIssueContributions; - result.commit_contributions += stats.totalCommitContributions; - result.pr_contributions += stats.totalPullRequestContributions; - result.review_contributions += - stats.totalPullRequestReviewContributions; - - // TODO: if there are 100 or more repositories, we should subdivide - // the date range in half - - for (stats.commitContributionsByRepository) |x| { - const raw_repo = x.repository; - if (seen.get(raw_repo.nameWithOwner) orelse false) { - std.log.debug( - "Skipping {s} (seen)", - .{raw_repo.nameWithOwner}, - ); - continue; - } - var repository = Repository{ - .name = try allocator.dupe(u8, raw_repo.nameWithOwner), - .stars = raw_repo.stargazerCount, - .forks = raw_repo.forkCount, - .private = raw_repo.isPrivate, - .languages = null, - .views = 0, - .lines_changed = 0, - }; - errdefer repository.deinit(allocator); - if (raw_repo.languages) |repo_languages| { - if (repo_languages.edges) |raw_languages| { - repository.languages = try allocator.alloc( - Language, - raw_languages.len, - ); - errdefer { - allocator.free(repository.languages.?); - repository.languages = null; - } - for ( - raw_languages, - repository.languages.?, - 0.., - ) |raw, *language, i| { - errdefer { - for (0..i, repository.languages.?) |_, l| { - allocator.free(l.name); - if (l.color) |c| allocator.free(c); - } - } - language.* = .{ - .name = try allocator.dupe(u8, raw.node.name), - .size = raw.size, - }; - errdefer allocator.free(language.name); - if (raw.node.color) |color| { - language.color = try allocator.dupe(u8, color); - } - errdefer if (language.color) |c| allocator.free(c); - } - } - } - - std.log.info( - "Getting views for {s}...", - .{raw_repo.nameWithOwner}, - ); - response, status = try client.rest( - try std.mem.concat( - arena.allocator(), - u8, - &.{ - "https://api.github.com/repos/", - raw_repo.nameWithOwner, - "/traffic/views", - }, - ), - ); - if (status == .ok) { - repository.views = (try std.json.parseFromSliceLeaky( - struct { count: u32 }, - arena.allocator(), - response, - .{ .ignore_unknown_fields = true }, - )).count; - } else { - std.log.info( - "Failed to get views for {s} ({?s})", - .{ raw_repo.nameWithOwner, status.phrase() }, - ); - } - - _ = try repository.get_lines_changed(arena, client, user); - - try seen.put(raw_repo.nameWithOwner, true); - try repositories.append(allocator, repository); - } } result.repositories = try repositories.toOwnedSlice(allocator); From 7922acac8ab929feac1f7cea40be654dd5838ec2 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sat, 28 Mar 2026 23:39:40 -0400 Subject: [PATCH 64/72] Move many arguments to a context object --- src/main.zig | 2 + src/statistics.zig | 110 +++++++++++++++++++++------------------------ 2 files changed, 53 insertions(+), 59 deletions(-) diff --git a/src/main.zig b/src/main.zig index a6bbfa6d787..52e3a5ee1dd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -71,6 +71,7 @@ fn overview(arena: *std.heap.ArenaAllocator, stats: anytype) ![]const u8 { const a = arena.allocator(); const template: []const u8 = @embedFile("templates/overview.svg"); var out_data = template; + // Vulnerable to template injection. In practice, this should never happen. inline for (@typeInfo(@TypeOf(stats)).@"struct".fields) |field| { switch (@typeInfo(field.type)) { .int => { @@ -143,6 +144,7 @@ fn languages(arena: *std.heap.ArenaAllocator, stats: anytype) ![]const u8 { \\ , .{ (i + 1) * 150, color orelse "#000", language, percent }); } + // Vulnerable to template injection. In practice, this should never happen. return try std.mem.replaceOwned(u8, a, try std.mem.replaceOwned( u8, a, diff --git a/src/statistics.zig b/src/statistics.zig index 0226af4c538..92ba322d15e 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -174,13 +174,15 @@ fn get_basic_info( } fn get_repos_by_year( - allocator: std.mem.Allocator, - arena: *std.heap.ArenaAllocator, - client: *HttpClient, - user: []const u8, - result: *Statistics, - seen: *std.StringHashMap(bool), - repositories: *std.ArrayList(Repository), + context: struct { + allocator: std.mem.Allocator, + arena: *std.heap.ArenaAllocator, + client: *HttpClient, + user: []const u8, + result: *Statistics, + seen: *std.StringHashMap(bool), + repositories: *std.ArrayList(Repository), + }, year: usize, start_month: usize, months: usize, @@ -189,7 +191,7 @@ fn get_repos_by_year( "Getting {d} month{s} of data starting from {d}/{d}...", .{ months, if (months != 1) "s" else "", start_month + 1, year }, ); - var response, var status = try client.graphql( + var response, var status = try context.client.graphql( \\query ($from: DateTime, $to: DateTime) { \\ viewer { \\ contributionsCollection(from: $from, to: $to) { @@ -224,12 +226,12 @@ fn get_repos_by_year( , .{ .from = try std.fmt.allocPrint( - arena.allocator(), + context.arena.allocator(), "{d}-{d:02}-01T00:00:00Z", .{ year, start_month + 1 }, ), .to = try std.fmt.allocPrint( - arena.allocator(), + context.arena.allocator(), "{d}-{d:02}-01T00:00:00Z", .{ year + (start_month + months) / 12, @@ -245,7 +247,7 @@ fn get_repos_by_year( ); return error.RequestFailed; } - const viewer = (std.json.parseFromSliceLeaky( + const stats = (try std.json.parseFromSliceLeaky( struct { data: struct { viewer: struct { contributionsCollection: struct { totalRepositoryContributions: u32, @@ -272,15 +274,10 @@ fn get_repos_by_year( }, }, } } }, - arena.allocator(), + context.arena.allocator(), response, .{ .ignore_unknown_fields = true }, - ) catch |err| { - std.debug.print("{s}\n", .{response}); - return err; - }).data.viewer; - - const stats = viewer.contributionsCollection; + )).data.viewer.contributionsCollection; std.log.info( "Parsed {d} total repositories from {d}", .{ stats.commitContributionsByRepository.len, year }, @@ -292,13 +289,7 @@ fn get_repos_by_year( if (months % factor == 0) { for (0..factor) |i| { try get_repos_by_year( - allocator, - arena, - client, - user, - result, - seen, - repositories, + context, year, start_month + (months / factor) * i, months / factor, @@ -315,16 +306,16 @@ fn get_repos_by_year( } } - result.repo_contributions += stats.totalRepositoryContributions; - result.issue_contributions += stats.totalIssueContributions; - result.commit_contributions += stats.totalCommitContributions; - result.pr_contributions += stats.totalPullRequestContributions; - result.review_contributions += + context.result.repo_contributions += stats.totalRepositoryContributions; + context.result.issue_contributions += stats.totalIssueContributions; + context.result.commit_contributions += stats.totalCommitContributions; + context.result.pr_contributions += stats.totalPullRequestContributions; + context.result.review_contributions += stats.totalPullRequestReviewContributions; for (stats.commitContributionsByRepository) |x| { const raw_repo = x.repository; - if (seen.get(raw_repo.nameWithOwner) orelse false) { + if (context.seen.get(raw_repo.nameWithOwner) orelse false) { std.log.debug( "Skipping {s} (seen)", .{raw_repo.nameWithOwner}, @@ -332,7 +323,7 @@ fn get_repos_by_year( continue; } var repository = Repository{ - .name = try allocator.dupe(u8, raw_repo.nameWithOwner), + .name = try context.allocator.dupe(u8, raw_repo.nameWithOwner), .stars = raw_repo.stargazerCount, .forks = raw_repo.forkCount, .private = raw_repo.isPrivate, @@ -340,15 +331,15 @@ fn get_repos_by_year( .views = 0, .lines_changed = 0, }; - errdefer repository.deinit(allocator); + errdefer repository.deinit(context.allocator); if (raw_repo.languages) |repo_languages| { if (repo_languages.edges) |raw_languages| { - repository.languages = try allocator.alloc( + repository.languages = try context.allocator.alloc( Language, raw_languages.len, ); errdefer { - allocator.free(repository.languages.?); + context.allocator.free(repository.languages.?); repository.languages = null; } for ( @@ -358,19 +349,19 @@ fn get_repos_by_year( ) |raw, *language, i| { errdefer { for (0..i, repository.languages.?) |_, l| { - allocator.free(l.name); - if (l.color) |c| allocator.free(c); + context.allocator.free(l.name); + if (l.color) |c| context.allocator.free(c); } } language.* = .{ - .name = try allocator.dupe(u8, raw.node.name), + .name = try context.allocator.dupe(u8, raw.node.name), .size = raw.size, }; - errdefer allocator.free(language.name); + errdefer context.allocator.free(language.name); if (raw.node.color) |color| { - language.color = try allocator.dupe(u8, color); + language.color = try context.allocator.dupe(u8, color); } - errdefer if (language.color) |c| allocator.free(c); + errdefer if (language.color) |c| context.allocator.free(c); } } } @@ -379,9 +370,9 @@ fn get_repos_by_year( "Getting views for {s}...", .{raw_repo.nameWithOwner}, ); - response, status = try client.rest( + response, status = try context.client.rest( try std.mem.concat( - arena.allocator(), + context.arena.allocator(), u8, &.{ "https://api.github.com/repos/", @@ -393,7 +384,7 @@ fn get_repos_by_year( if (status == .ok) { repository.views = (try std.json.parseFromSliceLeaky( struct { count: u32 }, - arena.allocator(), + context.arena.allocator(), response, .{ .ignore_unknown_fields = true }, )).count; @@ -404,10 +395,14 @@ fn get_repos_by_year( ); } - _ = try repository.get_lines_changed(arena, client, user); + _ = try repository.get_lines_changed( + context.arena, + context.client, + context.user, + ); - try seen.put(raw_repo.nameWithOwner, true); - try repositories.append(allocator, repository); + try context.seen.put(raw_repo.nameWithOwner, true); + try context.repositories.append(context.allocator, repository); } } @@ -440,18 +435,15 @@ fn get_repos( std.log.info("Getting data for user {s}...", .{user}); } for (years) |year| { - try get_repos_by_year( - allocator, - arena, - client, - user, - &result, - &seen, - &repositories, - year, - 0, - 12, - ); + try get_repos_by_year(.{ + .allocator = allocator, + .arena = arena, + .client = client, + .user = user, + .result = &result, + .seen = &seen, + .repositories = &repositories, + }, year, 0, 12); } result.repositories = try repositories.toOwnedSlice(allocator); From 85d53f12ffecb2bc6c0d68ecfdad2a30cb07ecf5 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 29 Mar 2026 00:05:56 -0400 Subject: [PATCH 65/72] Bump actions/checkout pinned version --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 51b9f6f578a..4ca326f2578 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Checkout history branch run: | git config --global user.name "jstrieb/github-stats" From ca71ce642f658ae222b139fa5d64aa10df8728e8 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 29 Mar 2026 00:26:08 -0400 Subject: [PATCH 66/72] Fix stripped debug logs in release builds --- src/main.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index 52e3a5ee1dd..2305948ddf3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,6 +9,9 @@ const Statistics = @import("statistics.zig"); pub const std_options: std.Options = .{ .logFn = logFn, + // Even though we change it later, this is necessary to ensure that debug + // logs aren't stripped in release builds. + .log_level = .debug, }; var log_level: std.log.Level = switch (builtin.mode) { @@ -32,6 +35,7 @@ const Args = struct { json_input_file: ?[]const u8 = null, json_output_file: ?[]const u8 = null, silent: bool = false, + debug: bool = false, verbose: bool = false, excluded_repos: ?[]const u8 = null, excluded_langs: ?[]const u8 = null, @@ -163,8 +167,10 @@ pub fn main() !void { defer args.deinit(allocator); if (args.silent) { log_level = .err; - } else if (args.verbose) { + } else if (args.debug) { log_level = .debug; + } else if (args.verbose) { + log_level = .info; } const excluded_repos = if (args.excluded_repos) |excluded| excluded: { var list = try std.ArrayList([]const u8).initCapacity(allocator, 16); From 471711c7eac1f8c14719fea0e8b2024863b93e96 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 29 Mar 2026 01:31:02 -0400 Subject: [PATCH 67/72] Fix possible (unlikely) double counting of lines --- src/statistics.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/statistics.zig b/src/statistics.zig index 92ba322d15e..103d77d674b 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -69,6 +69,7 @@ const Repository = struct { if (!std.mem.eql(u8, o.author.login, user)) { continue; } + self.lines_changed = 0; for (o.weeks) |week| { self.lines_changed += week.a; self.lines_changed += week.d; From 864885d5ea34e9fa0cc9d522310b4e7b67b1dc48 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 29 Mar 2026 01:39:06 -0400 Subject: [PATCH 68/72] Support using custom runtime templates --- src/main.zig | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/main.zig b/src/main.zig index 2305948ddf3..a6b0b2e4937 100644 --- a/src/main.zig +++ b/src/main.zig @@ -42,6 +42,8 @@ const Args = struct { exclude_private: bool = false, overview_output_file: ?[]const u8 = null, languages_output_file: ?[]const u8 = null, + overview_template: ?[]const u8 = null, + languages_template: ?[]const u8 = null, const Self = @This(); @@ -68,12 +70,17 @@ const Args = struct { if (self.excluded_langs) |s| allocator.free(s); if (self.overview_output_file) |s| allocator.free(s); if (self.languages_output_file) |s| allocator.free(s); + if (self.overview_template) |s| allocator.free(s); + if (self.languages_template) |s| allocator.free(s); } }; -fn overview(arena: *std.heap.ArenaAllocator, stats: anytype) ![]const u8 { +fn overview( + arena: *std.heap.ArenaAllocator, + stats: anytype, + template: []const u8, +) ![]const u8 { const a = arena.allocator(); - const template: []const u8 = @embedFile("templates/overview.svg"); var out_data = template; // Vulnerable to template injection. In practice, this should never happen. inline for (@typeInfo(@TypeOf(stats)).@"struct".fields) |field| { @@ -103,9 +110,12 @@ fn overview(arena: *std.heap.ArenaAllocator, stats: anytype) ![]const u8 { return out_data; } -fn languages(arena: *std.heap.ArenaAllocator, stats: anytype) ![]const u8 { +fn languages( + arena: *std.heap.ArenaAllocator, + stats: anytype, + template: []const u8, +) ![]const u8 { const a = arena.allocator(); - const template: []const u8 = @embedFile("templates/languages.svg"); const progress = try a.alloc([]const u8, stats.languages.count()); const lang_list = try a.alloc([]const u8, stats.languages.count()); for ( @@ -280,12 +290,26 @@ pub fn main() !void { try writeFile( args.overview_output_file orelse "overview.svg", - try overview(&arena, aggregate_stats), + try overview( + &arena, + aggregate_stats, + if (args.overview_template) |template| + try readFile(arena.allocator(), template) + else + @embedFile("templates/overview.svg"), + ), ); try writeFile( args.languages_output_file orelse "languages.svg", - try languages(&arena, aggregate_stats), + try languages( + &arena, + aggregate_stats, + if (args.languages_template) |template| + try readFile(arena.allocator(), template) + else + @embedFile("templates/languages.svg"), + ), ); } } From e53a8573329fb5fd89951e301bb1ac6abdf07b2b Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 29 Mar 2026 11:22:28 -0400 Subject: [PATCH 69/72] Tweak timeout --- src/statistics.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/statistics.zig b/src/statistics.zig index 103d77d674b..c48639fd029 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -515,7 +515,7 @@ fn get_lines_changed( // Exponential backoff (in expectation) with jitter item.delay += std.crypto.random.intRangeAtMost(i64, 2, item.delay); - item.delay = @min(item.delay, 240); + item.delay = @min(item.delay, 600); try q.add(item); }, else => |status| { From abde76572f099000b2ed195f86e3c5df6b3999de Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 29 Mar 2026 11:22:37 -0400 Subject: [PATCH 70/72] Cross-compile for many architectures --- build.zig | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/build.zig b/build.zig index f69ae1fce35..74d4843c466 100644 --- a/build.zig +++ b/build.zig @@ -1,16 +1,16 @@ const std = @import("std"); -pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); +pub fn build(b: *std.Build) !void { + const default_target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSafe, }); const exe = b.addExecutable(.{ - .name = "github_stats", + .name = "github-stats", .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), - .target = target, + .target = default_target, .optimize = optimize, }), }); @@ -28,4 +28,55 @@ pub fn build(b: *std.Build) void { const run_tests = b.addRunArtifact(tests); const test_step = b.step("test", "Run the tests"); test_step.dependOn(&run_tests.step); + + const release_step = b.step("release", "Cross-compile release binaries"); + const release_targets: []const std.Target.Query = &.{ + // Zig tier 1 supported compiler targets (manually tested) + .{ .cpu_arch = .x86_64, .os_tag = .linux }, + .{ .cpu_arch = .x86_64, .os_tag = .macos }, + // Zig tier 2 supported compiler targets (manually tested) + .{ .cpu_arch = .aarch64, .os_tag = .macos }, + .{ .cpu_arch = .x86_64, .os_tag = .windows }, + // Zig tier 2 supported compiler targets (untested) + .{ .cpu_arch = .aarch64, .os_tag = .freebsd }, + .{ .cpu_arch = .aarch64, .os_tag = .linux }, + .{ .cpu_arch = .aarch64, .os_tag = .netbsd }, + .{ .cpu_arch = .aarch64, .os_tag = .windows }, + .{ .cpu_arch = .arm, .os_tag = .freebsd }, + .{ .cpu_arch = .arm, .os_tag = .linux }, + .{ .cpu_arch = .arm, .os_tag = .netbsd }, + .{ .cpu_arch = .loongarch64, .os_tag = .linux }, + .{ .cpu_arch = .powerpc, .os_tag = .linux }, + .{ .cpu_arch = .powerpc, .os_tag = .netbsd }, + .{ .cpu_arch = .powerpc64, .os_tag = .freebsd }, + .{ .cpu_arch = .powerpc64, .os_tag = .linux }, + .{ .cpu_arch = .powerpc64le, .os_tag = .freebsd }, + .{ .cpu_arch = .powerpc64le, .os_tag = .linux }, + .{ .cpu_arch = .riscv32, .os_tag = .linux }, + .{ .cpu_arch = .riscv64, .os_tag = .freebsd }, + .{ .cpu_arch = .riscv64, .os_tag = .linux }, + .{ .cpu_arch = .thumb, .os_tag = .windows }, + .{ .cpu_arch = .thumb, .os_tag = .linux }, + // Fails with error due to networking + // .{ .cpu_arch = .wasm32, .os_tag = .wasi }, + .{ .cpu_arch = .x86, .os_tag = .linux }, + .{ .cpu_arch = .x86, .os_tag = .windows }, + .{ .cpu_arch = .x86_64, .os_tag = .freebsd }, + .{ .cpu_arch = .x86_64, .os_tag = .netbsd }, + }; + for (release_targets) |t| { + const cross_exe = b.addExecutable(.{ + .name = try std.fmt.allocPrint( + b.allocator, + "github-stats_{s}", + .{try t.zigTriple(b.allocator)}, + ), + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = b.resolveTargetQuery(t), + .optimize = .ReleaseFast, + }), + }); + release_step.dependOn(&b.addInstallArtifact(cross_exe, .{}).step); + } } From c5314afedbcf829ebcbc12be81e58e564a0e5b63 Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 29 Mar 2026 11:44:53 -0400 Subject: [PATCH 71/72] Add Actions workflow to build and upload releases --- .github/workflows/release.yml | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..8374f327e5b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Build Release Binaries + +on: + push: + tags: + - '*' + workflow_dispatch: + +defaults: + run: + shell: bash -euxo pipefail {0} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: mlugg/setup-zig@v2 + with: + version: 0.15.2 + + - name: Build + run: | + zig build release + + - name: Upload Release Artifacts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + ( + cd zig-out/bin/ + gh release create \ + "${TAG}" \ + --title "${TAG} Release" \ + * + ) From a25c8f5bb898b8b4021b07c7861b0db2142ddf5e Mon Sep 17 00:00:00 2001 From: Jacob Strieb Date: Sun, 29 Mar 2026 11:56:10 -0400 Subject: [PATCH 72/72] Tiny, non-functional tweak --- src/statistics.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/statistics.zig b/src/statistics.zig index c48639fd029..eeca8906d62 100644 --- a/src/statistics.zig +++ b/src/statistics.zig @@ -65,11 +65,11 @@ const Repository = struct { response, .{ .ignore_unknown_fields = true }, )); + self.lines_changed = 0; for (authors) |o| { if (!std.mem.eql(u8, o.author.login, user)) { continue; } - self.lines_changed = 0; for (o.weeks) |week| { self.lines_changed += week.a; self.lines_changed += week.d;