DEV Community

Alain Airom (Ayrom)
Alain Airom (Ayrom)

Posted on

Building a GitHub Contribution City: Transforming Developer History into a 3D Skyline

Build your contribution city!

Developer profiles are our digital resumes, but let's be honest: the standard 53-week GitHub contribution heatmap grid is starting to feel a bit flat. What if instead of looking at flat green squares, we could walk through a bustling cyberpunk metropolis, gaze up at towering skyscrapers built from our busiest commit weeks, and watch our coding history come alive in 3D?

In this post, we’ll explore GitHub Contribution City, a production-grade application built in Go that transforms any GitHub user's contribution history into a stunning 2D SVG skyline, a 3D isometric SVG, and an interactive WebGL 3D world powered by Three.js.

Before moving further, I want to clarify that the inspiration for this project comes from a blog post by Markus Eisele—a highly esteemed IBM colleague—which you can read here. Reading it gave me the idea to build this using IBM Bob. However, Markus is a true Java enthusiast who isn't particularly fond of languages like Python or JavaScript. Personally, I'm not a fan of Java :) so I asked Bob to adapt the codebase into Go instead. We all have our own brand of madness! 😵‍💫


Introduction & Project Inspiration

The initial spark for this project was inspired by Markus Eisele’s fantastic Quarkus Contribution City concept. While the original implementation was written in Java, we wanted to build a modern, lightning-fast, zero-dependency binary in Go that can run locally as a CLI tool, deploy as a high-performance web server, or run automatically via GitHub Actions.
Whether you want to generate a gorgeous SVG badge for your README.md, view your personal tech metropolis in 3D, or spin up an automated daily action that tracks your team's contributions, GitHub Contribution City turns your code commits into architectural marvels.

Detailed Architecture and Implementation

System Architecture

The application is structured around a clean modular pipeline: input configurations and remote API data feed into our core scene builder, which in turn delegates to specialized renderers.

Configuration Management (pkg/config)

The configuration manager handles environment variables, .env configuration files, and sensible defaults (such as defaulting to torvalds if no user is specified).

package config

import (
    "bufio"
    "os"
    "strconv"
    "strings"
)

// Config represents runtime configuration for GitHub Contribution City
type Config struct {
    GitHubToken    string `json:"github_token"`
    GitHubUsername string `json:"github_username"`
    Weeks          int    `json:"weeks"`
    MaxHeight      int    `json:"max_height"`
    Theme          string `json:"theme"`
    Port           int    `json:"port"`
    Host           string `json:"host"`
    OutputDir      string `json:"output_dir"`
    InputDir       string `json:"input_dir"`
}

// DefaultConfig returns default sensible parameters
func DefaultConfig() *Config {
    return &Config{
        GitHubToken:    "",
        GitHubUsername: "torvalds",
        Weeks:          53,
        MaxHeight:      14,
        Theme:          "github-dark",
        Port:           8088,
        Host:           "0.0.0.0",
        OutputDir:      "output",
        InputDir:       "input",
    }
}

// LoadConfig loads configuration from optional .env file and environment variables
func LoadConfig(envFile string) *Config {
    cfg := DefaultConfig()

    if envFile != "" {
        _ = loadDotEnv(envFile)
    } else if _, err := os.Stat(".env"); err == nil {
        _ = loadDotEnv(".env")
    }

    if val := os.Getenv("GITHUB_TOKEN"); val != "" {
        cfg.GitHubToken = strings.TrimSpace(val)
    }
    if val := os.Getenv("GITHUB_USERNAME"); val != "" {
        cfg.GitHubUsername = strings.TrimSpace(val)
    }
    if val := os.Getenv("CITY_WEEKS"); val != "" {
        if w, err := strconv.Atoi(val); err == nil && w >= 1 && w <= 53 {
            cfg.Weeks = w
        }
    }
    if val := os.Getenv("CITY_MAX_HEIGHT"); val != "" {
        if h, err := strconv.Atoi(val); err == nil && h >= 2 && h <= 50 {
            cfg.MaxHeight = h
        }
    }
    if val := os.Getenv("CITY_THEME"); val != "" {
        cfg.Theme = strings.TrimSpace(val)
    }
    if val := os.Getenv("PORT"); val != "" {
        if p, err := strconv.Atoi(val); err == nil && p > 0 {
            cfg.Port = p
        }
    }
    return cfg
}

func loadDotEnv(filepath string) error {
    file, err := os.Open(filepath)
    if err != nil {
        return err
    }
    defer file.Close()

    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }
        parts := strings.SplitN(line, "=", 2)
        if len(parts) == 2 {
            key := strings.TrimSpace(parts[0])
            val := strings.TrimSpace(parts[1])
            val = strings.Trim(val, `"'`)
            if os.Getenv(key) == "" {
                _ = os.Setenv(key, val)
            }
        }
    }
    return scanner.Err()
}
Enter fullscreen mode Exit fullscreen mode

Fetching Contribution Calendars (pkg/github)

The GitHub client seamlessly switches between authenticated GraphQL API queries (supporting private and internal commits if a PAT is provided) and a robust HTML scraper fallback for public profiles.

package github

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"
)

// ContributionDay represents one day's contributions
type ContributionDay struct {
    ContributionCount int       `json:"contributionCount"`
    ContributionLevel string    `json:"contributionLevel"`
    Date              string    `json:"date"`
    Weekday           int       `json:"weekday"`
    ParsedDate        time.Time `json:"-"`
}

// ContributionWeek represents a week of days (up to 7 days)
type ContributionWeek struct {
    ContributionDays []ContributionDay `json:"contributionDays"`
    FirstDay         string            `json:"firstDay"`
}

// ContributionCalendar is the complete calendar returned by GitHub
type ContributionCalendar struct {
    TotalContributions int                `json:"totalContributions"`
    Weeks              []ContributionWeek `json:"weeks"`
    Colors             []string           `json:"colors"`
    Username           string             `json:"username"`
}

// Client is a GitHub API client that can fetch contribution calendars
type Client struct {
    token      string
    httpClient *http.Client
    graphQLURL string
}

func NewClient(token string) *Client {
    return &Client{
        token: token,
        httpClient: &http.Client{
            Timeout: 15 * time.Second,
        },
        graphQLURL: "https://api.github.com/graphql",
    }
}

// FetchCalendar fetches contribution data using GraphQL or public scraping fallback
func (c *Client) FetchCalendar(ctx context.Context, username string) (*ContributionCalendar, error) {
    if c.token != "" {
        cal, err := c.fetchGraphQL(ctx, username)
        if err == nil && cal != nil {
            return cal, nil
        }
    }
    return c.fetchPublicCalendar(ctx, username)
}
Enter fullscreen mode Exit fullscreen mode

Logarithmic City Layout Algorithm (pkg/city)

To prevent single hyper-active weeks from completely distorting the skyline scale, building heights are calculated using logarithmic scaling:


// Calculate building heights using logarithmic scale as specified in Quarkus Contribution City:
buildingHeights := make([]int, numWeeks)
for i, tot := range weeklyTotals {
    if tot == 0 || maxWeekly == 0 {
        buildingHeights[i] = 1
    } else {
        ratio := math.Log1p(float64(tot)) / math.Log1p(float64(maxWeekly))
        h := 1 + int(math.Round(ratio * float64(opts.MaxHeight-1)))
        if h < 1 { h = 1 }
        if h > opts.MaxHeight { h = opts.MaxHeight }
        buildingHeights[i] = h
    }
}
Enter fullscreen mode Exit fullscreen mode

Main Application Entrypoint (cmd/city/main.go)

The main CLI dispatcher supports generating single SVGs, 3D isometric views, batch processing multiple usernames from an input folder, or starting an embedded HTTP server.


package main

import (
    "context"
    "encoding/json"
    "flag"
    "fmt"
    "log"
    "os"
    "path/filepath"
    "strings"
    "time"

    "github.com/alainairom/github-contribution-city/pkg/city"
    "github.com/alainairom/github-contribution-city/pkg/config"
    "github.com/alainairom/github-contribution-city/pkg/github"
    "github.com/alainairom/github-contribution-city/pkg/renderer"
)

func main() {
    var (
        serverMode = flag.Bool("server", false, "Start the web visualization server")
        username   = flag.String("user", "", "GitHub username to visualize")
        token      = flag.String("token", "", "GitHub Personal Access Token (PAT)")
        weeks      = flag.Int("weeks", 0, "Number of weeks to render (4 - 53)")
        maxHeight  = flag.Int("height", 0, "Maximum building height (3 - 30)")
        theme      = flag.String("theme", "", "Visual theme")
        format     = flag.String("format", "svg", "Export format: svg, iso, html, json, all")
        outFile    = flag.String("output", "", "Output filepath or directory")
        port       = flag.Int("port", 0, "HTTP server port")
        envFile    = flag.String("env", ".env", "Path to .env configuration file")
    )
    flag.Parse()

    cfg := config.LoadConfig(*envFile)
    if *username != "" { cfg.GitHubUsername = *username }
    if *token != "" { cfg.GitHubToken = *token }
    if *weeks > 0 { cfg.Weeks = *weeks }
    if *maxHeight > 0 { cfg.MaxHeight = *maxHeight }
    if *theme != "" { cfg.Theme = *theme }
    if *port > 0 { cfg.Port = *port }

    _ = os.MkdirAll(cfg.OutputDir, 0755)

    client := github.NewClient(cfg.GitHubToken)
    builder := city.NewBuilder()
    svgRenderer := renderer.NewSVGRenderer()
    htmlRenderer := renderer.NewHTMLRenderer()

    if *serverMode {
        runServer(cfg, client, builder, svgRenderer, htmlRenderer)
        return
    }

    fmt.Printf("🏙️  Generating Contribution City for '%s'...\n", cfg.GitHubUsername)
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()

    cal, err := client.FetchCalendar(ctx, cfg.GitHubUsername)
    if err != nil {
        log.Fatalf("❌ Error fetching contribution data: %v", err)
    }

    scene := builder.Build(cal, city.Options{
        Weeks:     cfg.Weeks,
        MaxHeight: cfg.MaxHeight,
        Theme:     cfg.Theme,
    })

    outPath := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s_city.svg", cfg.GitHubUsername))
    svgContent := svgRenderer.RenderSkyline(scene, cfg.Theme)
    _ = os.WriteFile(outPath, []byte(svgContent), 0644)
    fmt.Printf("✨ SVG Skyline successfully written to: %s\n", outPath)
}
Enter fullscreen mode Exit fullscreen mode

Automation with GitHub Actions

To keep your contribution city continuously updated without manual intervention, the repository features an automated CI/CD and scheduling suite using GitHub Actions.
The workflow automation includes:

  • Continuous Integration (ci.yml): Runs linting checks, go vet, vulnerability scans via govulncheck, multi-OS unit tests (ubuntu, macos, windows), and cross-compilation on every push and pull request.
  name: Continuous Integration

  on:
    push:
      branches:
        - main
        - master
      paths-ignore:
        - 'Docs/**'
        - 'README.md'
        - 'output/**'
    pull_request:
      branches:
        - main
        - master

  permissions:
    contents: read

  jobs:
    lint:
      name: Lint & Code Style
      runs-on: ubuntu-latest
      steps:
        - name: Checkout Source Code
          uses: actions/checkout@v4

        - name: Setup Go
          uses: actions/setup-go@v5
          with:
            go-version: '1.24'
            cache: true

        - name: Verify Formatting
          run: |
            UNFORMATTED=$(gofmt -l . | grep -v '^vendor/' || true)
            if [ -n "$UNFORMATTED" ]; then
              echo "❌ Code format issues found in following files:"
              echo "$UNFORMATTED"
              exit 1
            fi
            echo "✅ All Go files formatted properly."

        - name: Run Go Vet
          run: go vet ./...

        - name: Run golangci-lint
          uses: golangci/golangci-lint-action@v6
          with:
            version: latest
            args: --timeout=5m
          continue-on-error: true

    vuln-scan:
      name: Security & Vulnerability Scan
      runs-on: ubuntu-latest
      steps:
        - name: Checkout Source Code
          uses: actions/checkout@v4

        - name: Setup Go
          uses: actions/setup-go@v5
          with:
            go-version: '1.24'
            cache: true

        - name: Run govulncheck
          uses: golang/govulncheck-action@v1
          with:
            go-version-input: '1.24'
            go-package: ./...

    ...
Enter fullscreen mode Exit fullscreen mode
  • Daily Visualization Updates (update-city.yml): Powered by a scheduled cron trigger (03:17 UTC), this workflow fetches fresh contribution matrices for the repository owner and any tracked usernames listed inside the input/ folder, regenerates the respective skylines, and commits the updated graphics back to the repository automatically ([skip ci]).
  name: Daily Contribution City Update

  on:
    schedule:
      # Runs everyday at 03:17 UTC
      - cron: '17 3 * * *'
    workflow_dispatch:
      inputs:
        custom_user:
          description: 'Optional specific GitHub username to generate for'
          required: false
          default: ''
        theme:
          description: 'Theme to use (github-dark, github-light, cyberpunk, mono, neon, matrix)'
          required: false
          default: 'github-dark'

  permissions:
    contents: write

  jobs:
    update-visualizations:
      name: Regenerate Contribution Cities
      runs-on: ubuntu-latest
      steps:
        - name: Checkout Repository
          uses: actions/checkout@v4
          with:
            fetch-depth: 0

        - name: Set up Go
          uses: actions/setup-go@v5
          with:
            go-version: '1.24'
            cache: true

        - name: Build Contribution City CLI
          run: |
            go build -o bin/github-contribution-city cmd/city/main.go

        - name: Generate Visualizations
          env:
            GITHUB_TOKEN: ${{ secrets.CONTRIBUTION_TOKEN || secrets.GITHUB_TOKEN }}
          run: |
            mkdir -p output
            PRIMARY_USER="${{ inputs.custom_user || github.repository_owner }}"
            SELECTED_THEME="${{ inputs.theme || 'github-dark' }}"

            echo "🌆 Generating contribution city for primary user: ${PRIMARY_USER} (Theme: ${SELECTED_THEME})..."

            # 1. Generate primary user 2D SVG Skyline
            ./bin/github-contribution-city \
              -user "${PRIMARY_USER}" \
              -weeks 53 \
              -height 14 \
              -theme "${SELECTED_THEME}" \
              -format svg \
              -output "output/contribution-city.svg"

            # 2. Generate primary user 3D Isometric SVG
            ./bin/github-contribution-city \
              -user "${PRIMARY_USER}" \
              -weeks 53 \
              -height 14 \
              -theme "cyberpunk" \
              -format iso \
              -output "output/contribution-city-3d.svg"

            # 3. Generate primary user 3D Interactive HTML
            ./bin/github-contribution-city \
              -user "${PRIMARY_USER}" \
              -weeks 53 \
              -height 14 \
              -theme "${SELECTED_THEME}" \
              -format html \
              -output "output/contribution-city.html"

            # 4. If tracked users exist in input/ directory, batch process them
            if [ -d "input" ] && [ "$(ls -A input 2>/dev/null)" ]; then
              echo "📁 Processing batch users from input directory..."
              ./bin/github-contribution-city -batch input/ -output output/
            fi

        - name: Commit and Push Changes
          run: |
            git config --global user.name "github-actions[bot]"
            git config --global user.email "github-actions[bot]@users.noreply.github.com"
            git add output/

            if git diff --staged --quiet; then
              echo "ℹ️  No changes detected in contribution calendars. Skipping commit."
            else
              git commit -m "chore(city): auto-update contribution city visualizations [skip ci]"
              git push
              echo "✅ Successfully committed and pushed updated visualizations."
            fi

        - name: Upload Output Artifacts
          uses: actions/upload-artifact@v4
          with:
            name: contribution-city-visualizations
            path: output/
            retention-days: 30

Enter fullscreen mode Exit fullscreen mode
  • Container & Release Management (docker.yml & release.yml): Automatically builds multi-arch container images (linux/amd64, linux/arm64) for the GitHub Container Registry (ghcr.io) and packages cross-compiled release binaries whenever version tags are pushed.

Conclusion

Building GitHub Contribution City was a fun journey merging developer metrics with procedural city generation and graphics rendering. By leveraging Go, we achieved a blazing fast binary that can be embedded directly into CI/CD pipelines, containerized for Docker/Podman, or served live via an interactive Three.js 3D browser experience.

Thanks for reading 🌃

Links

Top comments (0)