diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..f68f22c
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,29 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: ['*']
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+# Cancel in-progress runs for the same ref (e.g. a new push to an open PR).
+concurrency:
+ group: tests-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ - name: Set up Python
+ uses: actions/setup-python@v7
+ with:
+ python-version: '3.x'
+
+ - name: Run tests
+ run: python3 -m unittest discover -s __test__ -p 'test_*.py' -v
diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml
index b8ba7be..ab45e38 100644
--- a/.github/workflows/update-readme.yml
+++ b/.github/workflows/update-readme.yml
@@ -5,9 +5,15 @@ on:
schedule:
- cron: '0 0 * * *' # Daily at 00:00 UTC
+# Cancel in-progress runs for the same ref.
+concurrency:
+ group: update-readme-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
update-readme:
runs-on: ubuntu-latest
+ timeout-minutes: 5
permissions:
contents: write
@@ -32,6 +38,13 @@ jobs:
git status --porcelain
git diff --stat
+ - name: Validate generated artifacts
+ run: |
+ set -e
+ test -s README.md || { echo "README.md is empty"; exit 1; }
+ python3 -c "import json,sys; d=json.load(open('README_DATA.json')); sys.exit(1 if not d.get('years') else 0)" \
+ || { echo "README_DATA.json is empty or invalid"; exit 1; }
+
- name: Commit and push changes
run: |
git config --local user.name "github-actions[bot]"
diff --git a/__test__/helpers.py b/__test__/helpers.py
new file mode 100644
index 0000000..eb155d3
--- /dev/null
+++ b/__test__/helpers.py
@@ -0,0 +1,47 @@
+"""Shared fixtures/helpers for the OSS contributions test suite."""
+
+import os
+import sys
+from collections import defaultdict
+
+sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from src import model
+
+import datetime as _dt
+
+
+def mock_pr(title, url, number, state, repo, date, is_draft=False):
+ return {
+ 'title': title,
+ 'url': url,
+ 'number': number,
+ 'state': state,
+ 'isDraft': is_draft,
+ 'status': 'DRAFT' if is_draft else state,
+ 'repository': {'nameWithOwner': repo},
+ 'createdAt': date,
+ 'sheet_index': 0,
+ 'repo_info': {'description': '', 'tech_stack': 'Python'},
+ }
+
+
+def grouped_mock(data):
+ """Mirror the grouping done by model.fetch_urls."""
+ contributions_by_date = defaultdict(lambda: defaultdict(list))
+ for item in data:
+ year = int(item['createdAt'][:4])
+ month_num = int(item['createdAt'][5:7])
+ month_name = _dt.datetime(year, month_num, 1).strftime('%B')
+ contributions_by_date[year][(month_num, month_name)].append(item)
+ return contributions_by_date, {}
+
+
+SAMPLE_DATA = [
+ mock_pr("Fix 2026 Issue", "http://github.com/a/b/1", 101, "OPEN", "repo/a", "2026-01-15T10:00:00Z"),
+ mock_pr("Another 2026 Fix", "http://github.com/a/b/2", 102, "MERGED", "repo/a", "2026-01-10T10:00:00Z"),
+ mock_pr("feat: new thing", "http://github.com/c/d/1", 1, "OPEN", "repo/b", "2026-01-05T10:00:00Z"),
+ mock_pr("Late 2025 Fix", "http://github.com/a/b/3", 100, "MERGED", "repo/a", "2025-12-20T10:00:00Z"),
+ mock_pr("Mid 2025 Feature", "http://github.com/e/f/1", 50, "CLOSED", "repo/c", "2025-06-15T10:00:00Z"),
+ mock_pr("WIP draft", "http://github.com/g/h/1", 7, "OPEN", "repo/d", "2024-01-01T10:00:00Z", is_draft=True),
+]
diff --git a/__test__/test_config.py b/__test__/test_config.py
new file mode 100644
index 0000000..fa7d010
--- /dev/null
+++ b/__test__/test_config.py
@@ -0,0 +1,33 @@
+"""Tests for emoji detection and GitHub URL validation (src.config / model)."""
+
+import unittest
+
+from src import config, model
+
+
+class TestPrEmoji(unittest.TestCase):
+ def test_conventional_prefixes(self):
+ self.assertEqual(model.get_pr_emoji("feat: add login"), config.CONVENTIONAL_EMOJI['feat'])
+ self.assertEqual(model.get_pr_emoji("fix(core): segfault"), config.CONVENTIONAL_EMOJI['fix'])
+ self.assertEqual(model.get_pr_emoji("docs: update readme"), config.CONVENTIONAL_EMOJI['docs'])
+
+ def test_keyword_fallback(self):
+ self.assertEqual(model.get_pr_emoji("update dependencies"), config.KEYWORD_EMOJI['update'])
+ self.assertEqual(model.get_pr_emoji("remove legacy code"), config.KEYWORD_EMOJI['remove'])
+
+ def test_default_fallback(self):
+ self.assertEqual(model.get_pr_emoji("misc change"), config.DEFAULT_PR_EMOJI)
+
+
+class TestGithubUrl(unittest.TestCase):
+ def test_valid(self):
+ self.assertTrue(config.is_github_url("https://github.com/o/r/pull/1"))
+ self.assertTrue(config.is_github_url("http://github.com/o/r"))
+
+ def test_invalid(self):
+ self.assertFalse(config.is_github_url("https://gitlab.com/o/r/pull/1"))
+ self.assertFalse(config.is_github_url("not a url"))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/__test__/test_fetch.py b/__test__/test_fetch.py
new file mode 100644
index 0000000..ddcd684
--- /dev/null
+++ b/__test__/test_fetch.py
@@ -0,0 +1,40 @@
+"""Tests for data fetching/filtering in src.model.fetch_urls."""
+
+import contextlib
+import io
+import unittest
+
+from src import model
+
+
+class TestFetchUrlsFiltering(unittest.TestCase):
+ def test_status_filtering_excludes_disallowed(self):
+ url_data = [
+ {'url': 'https://github.com/o/r/pull/1', 'featured': False,
+ 'featured_order': float('inf'), 'sheet_index': 0},
+ {'url': 'https://github.com/o/r/pull/2', 'featured': False,
+ 'featured_order': float('inf'), 'sheet_index': 1},
+ ]
+
+ def fake_pr_details(url):
+ num = 1 if 'pull/1' in url else 2
+ state = 'OPEN' if num == 1 else 'CLOSED'
+ return {
+ 'title': f'PR {num}', 'url': url, 'number': num,
+ 'state': state, 'isDraft': False,
+ 'repository': {'nameWithOwner': 'o/r'},
+ 'createdAt': '2026-01-01T00:00:00Z',
+ }
+
+ model.get_pr_details = fake_pr_details
+ model.get_repo_details = lambda repo: {'description': '', 'tech_stack': 'Python'}
+
+ with contextlib.redirect_stdout(io.StringIO()):
+ data, _ = model.fetch_urls(url_data, allowed_statuses={'OPEN'})
+ rows = data[2026][(1, 'January')]
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0]['status'], 'OPEN')
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/__test__/test_gen.py b/__test__/test_gen.py
deleted file mode 100644
index a844dd2..0000000
--- a/__test__/test_gen.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import sys
-import os
-from collections import defaultdict
-
-# Add parent directory to path to import generate_readme
-sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-from generate_readme import generate_markdown, generate_json_snapshot
-
-def create_mock_pr(title, url, number, state, repo, date):
- return {
- 'title': title,
- 'url': url,
- 'number': number,
- 'state': state,
- 'status': state, # Add status field for new logic
- 'repository': {'nameWithOwner': repo},
- 'createdAt': date
- }
-
-def main():
- contributions_by_date = defaultdict(lambda: defaultdict(list))
-
- # Mock data spanning 3 years
- data = [
- # 2026
- create_mock_pr("Fix 2026 Issue", "http://github.com/a/b/1", 101, "OPEN", "repo/a", "2026-01-15T10:00:00Z"),
- create_mock_pr("Another 2026 Fix", "http://github.com/a/b/2", 102, "MERGED", "repo/a", "2026-01-10T10:00:00Z"),
- create_mock_pr("Feature 2026", "http://github.com/c/d/1", 1, "OPEN", "repo/b", "2026-01-05T10:00:00Z"),
-
- # 2025
- create_mock_pr("Late 2025 Fix", "http://github.com/a/b/3", 100, "MERGED", "repo/a", "2025-12-20T10:00:00Z"),
- create_mock_pr("Mid 2025 Feature", "http://github.com/e/f/1", 50, "CLOSED", "repo/c", "2025-06-15T10:00:00Z"),
-
- # 2024
- create_mock_pr("Old 2024 PR", "http://github.com/a/b/0", 10, "MERGED", "repo/a", "2024-01-01T10:00:00Z"),
- ]
-
- for item in data:
- # Manually grouping as fetch_urls does
- year = int(item['createdAt'][:4])
- month_num = int(item['createdAt'][5:7])
- # Simple month name mapping for test
- month_names = {1: "January", 6: "June", 12: "December"}
- month_name = month_names[month_num]
-
- # Add mock repo info
- item['repo_info'] = {
- 'description': f"Description for {item['repository']['nameWithOwner']}",
- 'tech_stack': "Python"
- }
-
- contributions_by_date[year][(month_num, month_name)].append(item)
-
- output_file = os.path.join(os.path.dirname(__file__), "TEST_README.md")
- output_json = os.path.join(os.path.dirname(__file__), "TEST_README_DATA.json")
- print(f"Generating {output_file}...")
-
- # No featured repos in test
- featured_repos = {}
-
- generate_markdown(contributions_by_date, featured_repos, output_file)
- generate_json_snapshot(contributions_by_date, featured_repos, output_json)
- print("Done.")
-
-if __name__ == "__main__":
- main()
diff --git a/__test__/test_model.py b/__test__/test_model.py
new file mode 100644
index 0000000..7e82327
--- /dev/null
+++ b/__test__/test_model.py
@@ -0,0 +1,74 @@
+"""Tests for model building and rendering consistency (src.model)."""
+
+import unittest
+
+from src import config, model
+from helpers import SAMPLE_DATA, grouped_mock
+
+
+class TestBuildModel(unittest.TestCase):
+ def setUp(self):
+ self.data, self.featured = grouped_mock(SAMPLE_DATA)
+ self.m = model.build_readme_model(self.data, self.featured)
+
+ def test_title(self):
+ self.assertEqual(self.m['title'], 'OSS Contributions')
+
+ def test_years_descending(self):
+ years = [y['year'] for y in self.m['years']]
+ self.assertEqual(years, sorted(years, reverse=True))
+ self.assertEqual(years, [2026, 2025, 2024])
+
+ def test_months_descending(self):
+ for year in self.m['years']:
+ months = [mo['month_number'] for mo in year['months']]
+ self.assertEqual(months, sorted(months, reverse=True))
+
+ def test_status_icon_mapping(self):
+ rows_2026 = self.m['years'][0]['months'][0]['rows']
+ statuses = {(r['status'], r['status_icon']) for r in rows_2026}
+ self.assertIn(('OPEN', config.STATUS_ICONS['OPEN']), statuses)
+ self.assertIn(('MERGED', config.STATUS_ICONS['MERGED']), statuses)
+
+ def test_pr_emoji_in_contributions(self):
+ rows_2026 = self.m['years'][0]['months'][0]['rows']
+ repo_b = next(r for r in rows_2026 if r['repo_name'] == 'repo/b')
+ self.assertEqual(repo_b['contributions'][0]['emoji'], config.CONVENTIONAL_EMOJI['feat'])
+
+ def test_status_legend_present(self):
+ self.assertEqual(len(self.m['status_legend']), len(config.STATUS_LEGEND))
+
+
+class TestRenderConsistency(unittest.TestCase):
+ def test_markdown_and_json_agree_on_counts(self):
+ data, featured = grouped_mock(SAMPLE_DATA)
+ m = model.build_readme_model(data, featured)
+ md_prs = sum(len(r['contributions']) for y in m['years'] for mo in y['months'] for r in mo['rows'])
+ self.assertEqual(md_prs, len(SAMPLE_DATA))
+
+
+class TestBuildModelFeatured(unittest.TestCase):
+ def test_featured_projects_sorted_by_order(self):
+ url_data = [
+ {'url': 'https://github.com/o/b/pull/1', 'featured': True,
+ 'featured_order': 5.0, 'sheet_index': 0},
+ {'url': 'https://github.com/o/a/pull/2', 'featured': True,
+ 'featured_order': 2.0, 'sheet_index': 1},
+ ]
+ model.get_pr_details = lambda url: {
+ 'title': 't', 'url': url, 'number': 1, 'state': 'OPEN',
+ 'isDraft': False, 'repository': {'nameWithOwner': 'o/b' if 'b/' in url else 'o/a'},
+ 'createdAt': '2026-01-01T00:00:00Z',
+ }
+ model.get_repo_details = lambda repo: {'description': '', 'tech_stack': 'Python'}
+
+ import contextlib, io
+ with contextlib.redirect_stdout(io.StringIO()):
+ data, featured = model.fetch_urls(url_data)
+ m = model.build_readme_model(data, featured)
+ names = [p['repo_name'] for p in m['featured_projects']]
+ self.assertEqual(names, ['o/a', 'o/b'])
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/__test__/test_sheet.py b/__test__/test_sheet.py
new file mode 100644
index 0000000..ef2cf8a
--- /dev/null
+++ b/__test__/test_sheet.py
@@ -0,0 +1,44 @@
+"""Tests for Google Sheet CSV parsing (src.sheet)."""
+
+import csv
+import io
+import unittest
+
+from src import sheet
+
+
+class TestSheetParsing(unittest.TestCase):
+ CSV = (
+ "Status,Value,PR,Featured,FeaturedOrder\n"
+ "OPEN,1,https://github.com/o/r/pull/1,YES,2\n"
+ "MERGED,1,https://github.com/o/r/pull/2,NO,\n"
+ "CLOSED,0,https://github.com/o/r/pull/3,,\n"
+ "OPEN,1,not-a-url,,\n"
+ ",,https://github.com/o/r/pull/4,,\n"
+ )
+
+ def test_fetch_urls_from_sheet(self):
+ url_data, allowed = sheet._parse_sheet(csv.DictReader(io.StringIO(self.CSV)))
+ # invalid URL (not-a-url) skipped; the CLOSED row still carries a valid PR URL
+ self.assertEqual(len(url_data), 4)
+ self.assertIn('OPEN', allowed)
+ self.assertIn('MERGED', allowed)
+ self.assertNotIn('CLOSED', allowed)
+
+ def test_featured_parsing(self):
+ url_data, _ = sheet._parse_sheet(csv.DictReader(io.StringIO(self.CSV)))
+ featured = [u for u in url_data if u['featured']]
+ self.assertEqual(len(featured), 1)
+ self.assertEqual(featured[0]['featured_order'], 2.0)
+ self.assertEqual(featured[0]['url'], "https://github.com/o/r/pull/1")
+
+
+class TestAllowedStatuses(unittest.TestCase):
+ def test_parses_values(self):
+ csv_content = "Status,Value\nOPEN,1\nCLOSED,0\nMERGED,1\n"
+ result = sheet._parse_allowed(csv.DictReader(io.StringIO(csv_content)))
+ self.assertEqual(result, {'OPEN', 'MERGED'})
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/generate_readme.py b/generate_readme.py
index 3aa952a..0bbadff 100644
--- a/generate_readme.py
+++ b/generate_readme.py
@@ -1,508 +1,12 @@
-import subprocess
-import re
-import json
-from collections import defaultdict
-from urllib.parse import urlparse
-import csv
-import urllib.request
-import io
+"""
+Legacy entry point.
-def get_pr_details(url):
- """
- Fetches PR details using gh cli.
- """
- try:
- # Check if it's a valid GitHub URL
- parsed = urlparse(url)
- if parsed.netloc != "github.com":
- return None
-
- # Run gh pr view
- # We want json output with title, url, state, createdAt, number, isDraft
- cmd = [
- "gh", "pr", "view", url,
- "--json", "title,url,state,createdAt,number,isDraft"
- ]
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
- data = json.loads(result.stdout)
-
- # Parse repository from URL manually since it's not always available in json fields or named differently
- path_parts = parsed.path.strip("/").split("/")
- if len(path_parts) >= 2:
- data['repository'] = {'nameWithOwner': f"{path_parts[0]}/{path_parts[1]}"}
- else:
- data['repository'] = {'nameWithOwner': "unknown/unknown"}
-
- return data
- except subprocess.CalledProcessError as e:
- print(f"Error fetching {url}: {e.stderr}")
- return None
- except Exception as e:
- print(f"Error processing {url}: {e}")
- return None
+The implementation now lives in the ``src`` package
+(config, github, sheet, model, render, cli). Running
+``python generate_readme.py`` still triggers the generator via ``cli.main``.
+"""
-repo_cache = {}
-
-def get_repo_details(repo_name):
- """
- Fetches repo details (description, primary language, topics) using gh cli.
- Caches results to avoid repeated calls.
- """
- if repo_name in repo_cache:
- return repo_cache[repo_name]
-
- try:
- cmd = [
- "gh", "repo", "view", repo_name,
- "--json", "description,primaryLanguage,repositoryTopics"
- ]
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
- data = json.loads(result.stdout)
-
- # Get Language
- language = data.get('primaryLanguage', {}).get('name', '') if data.get('primaryLanguage') else ''
-
- # Get Topics and map interesting ones
- topics_data = data.get('repositoryTopics')
- topics = [t['name'] for t in topics_data] if topics_data else []
- tech_stack = [language] if language else []
-
- # Mapping for better display names
- topic_map = {
- 'compose': 'Jetpack Compose',
- 'react': 'React',
- 'nextjs': 'Next.js',
- 'typescript': 'TypeScript',
- 'rust': 'Rust'
- }
-
- for t in topics:
- if t in topic_map:
- name = topic_map[t]
- if name not in tech_stack:
- tech_stack.append(name)
-
- info = {
- 'description': data.get('description', ''),
- 'tech_stack': ", ".join(tech_stack)
- }
- repo_cache[repo_name] = info
- return info
- except Exception as e:
- print(f"Error fetching repo info for {repo_name}: {e}")
- return {'description': '', 'tech_stack': ''}
-
-
-def fetch_allowed_statuses(csv_url):
- """
- Fetches allowed statuses from a Google Sheet CSV.
- Expected columns: Status, Value (1 for allowed, 0 for hidden)
- """
- if not csv_url:
- print("Warning: FILTER_SHEET_URL is not set. All statuses will be allowed.")
- return None
-
- try:
- response = urllib.request.urlopen(csv_url)
- csv_content = response.read().decode('utf-8')
- reader = csv.DictReader(io.StringIO(csv_content))
-
- allowed = set()
- for row in reader:
- if 'Status' in row and 'Value' in row:
- if row['Value'].strip() == '1':
- allowed.add(row['Status'].strip().upper())
- return allowed
- except Exception as e:
- print(f"Error fetching allowed statuses: {e}")
- return None
-
-def fetch_urls(url_data, allowed_statuses=None):
- contributions_by_date = defaultdict(lambda: defaultdict(list))
- featured_repos = {} # repo_name -> min_order
-
- from datetime import datetime
-
- for entry in url_data:
- url = entry['url']
- is_featured = entry.get('featured', False)
- featured_order = entry.get('featured_order', float('inf'))
- sheet_index = entry.get('sheet_index', 0) # Preserve sheet order
-
- print(f"Processing {url}...")
- details = get_pr_details(url)
- if details:
- # Use API status (mapped to upper case)
- if details.get('isDraft'):
- status = 'DRAFT'
- else:
- status = details['state'].upper()
-
- # Filter based on allowed statuses
- if allowed_statuses is not None:
- if status not in allowed_statuses:
- print(f"Skipping {url} (Status: {status} not in allowed list)")
- continue
-
- details['status'] = status
- details['sheet_index'] = sheet_index # Store sheet order
-
- # Parse date
- created_at = datetime.strptime(details['createdAt'], "%Y-%m-%dT%H:%M:%SZ")
- year = created_at.year
- month_name = created_at.strftime("%B") # Full month name
- month_sort = created_at.month # Keep month number for sorting
-
- # Fetch extra repo data
- if 'repository' in details:
- repo_name = details['repository'].get('nameWithOwner')
- if repo_name:
- repo_info = get_repo_details(repo_name)
- details['repo_info'] = repo_info
- if is_featured:
- # Keep the lowest order found for this repo
- current_order = featured_repos.get(repo_name, float('inf'))
- if repo_name not in featured_repos or featured_order < current_order:
- featured_repos[repo_name] = featured_order
-
- # Store tuple of (month_number, month_name) for sorting keys properly later
- contributions_by_date[year][(month_sort, month_name)].append(details)
-
- return contributions_by_date, featured_repos
-
-def get_pr_emoji(title):
- """
- Detects emoji based on PR title using conventional commit prefixes or keywords.
- """
- title_lower = title.lower().strip()
-
- # Conventional commit emoji mapping
- conventional_map = {
- 'feat': '✨', # New features
- 'fix': '🐛', # Bug fixes
- 'refactor': '♻️', # Code refactoring
- 'docs': '📝', # Documentation
- 'style': '💄', # Styling/formatting
- 'test': '✅', # Tests
- 'chore': '🔧', # Maintenance
- 'perf': '⚡', # Performance
- 'ci': '👷', # CI/CD
- 'build': '📦', # Build system
- 'revert': '⏪', # Reverts
- }
-
- # Check for conventional commit prefix (e.g., "feat:", "fix(core):")
- import re
- match = re.match(r'^(\w+)(?:\([^)]+\))?:', title_lower)
- if match:
- prefix = match.group(1)
- if prefix in conventional_map:
- return conventional_map[prefix]
-
- # Fallback: keyword detection
- keyword_map = {
- 'add': '✨',
- 'update': '🔄',
- 'remove': '🗑️',
- 'delete': '🗑️',
- 'migrate': '🚚',
- 'improve': '⚡',
- 'enhance': '⚡',
- 'optimize': '⚡',
- 'bump': '⬆️',
- 'upgrade': '⬆️',
- 'deprecate': '🚨',
- }
-
- for keyword, emoji in keyword_map.items():
- if title_lower.startswith(keyword):
- return emoji
-
- # Default fallback
- return '🔨'
-
-def build_readme_model(contributions_by_date, featured_repos):
- """
- Builds a deterministic model used by both markdown and JSON outputs.
- """
- custom_logos = {
- 'ImranR98/Obtainium': 'https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/icon_small.png'
- }
-
- status_icons = {
- 'DRAFT': '🚧',
- 'OPEN': '🟢',
- 'MERGED': '🟣',
- 'CLOSED': '🔴',
- }
-
- status_legend = [
- {'status': 'OPEN', 'icon': '🟢', 'label': 'Open', 'description': 'The pull request is currently open and active.'},
- {'status': 'MERGED', 'icon': '🟣', 'label': 'Merged', 'description': 'The pull request has been merged into the codebase.'},
- {'status': 'DRAFT', 'icon': '🚧', 'label': 'Draft', 'description': 'The pull request is a work in progress.'},
- {'status': 'CLOSED', 'icon': '🔴', 'label': 'Closed', 'description': 'The pull request was closed without being merged.'},
- ]
-
- featured_projects = []
- if featured_repos:
- sorted_featured = sorted(featured_repos.items(), key=lambda item: (item[1], item[0].lower()))
- for repo, order in sorted_featured:
- owner = repo.split('/')[0]
- featured_projects.append({
- 'repo_name': repo,
- 'order': order,
- 'repo_url': f"https://github.com/{repo}",
- 'avatar_url': f"https://github.com/{owner}.png",
- })
-
- years = []
- sorted_years = sorted(contributions_by_date.keys(), reverse=True)
-
- for year in sorted_years:
- months = []
- sorted_months = sorted(contributions_by_date[year].keys(), key=lambda x: x[0], reverse=True)
-
- for month_sort, month_name in sorted_months:
- prs = list(contributions_by_date[year][(month_sort, month_name)])
-
- # Sort by (repo, normalized status, sheet_index) so that groupby produces one
- # group per (repo, status) pair even when statuses are interleaved by sheet order.
- prs.sort(key=lambda x: (
- x['repository']['nameWithOwner'].lower(),
- x.get('status', 'OPEN').upper(),
- x.get('sheet_index', 0),
- ))
-
- month_rows = []
- from itertools import groupby
- grouped_prs = groupby(prs, key=lambda x: (x['repository']['nameWithOwner'], x.get('status', 'OPEN').upper()))
-
- for (repo_name, group_status), repo_prs in grouped_prs:
- repo_prs_list = list(repo_prs)
- first_pr = repo_prs_list[0]
-
- icon = status_icons.get(group_status, '🔨')
- tech_stack = first_pr.get('repo_info', {}).get('tech_stack', '')
- owner = repo_name.split('/')[0]
- logo_url = custom_logos.get(repo_name, f"https://github.com/{owner}.png")
-
- contributions = []
- for pr in repo_prs_list:
- emoji = get_pr_emoji(pr['title'])
- contributions.append({
- 'emoji': emoji,
- 'number': pr['number'],
- 'title': pr['title'],
- 'url': pr['url'],
- 'markdown': f"{emoji} [#{pr['number']}: {pr['title']}]({pr['url']})",
- })
-
- month_rows.append({
- 'status': group_status,
- 'status_icon': icon,
- 'repo_name': repo_name,
- 'repo_url': f"https://github.com/{repo_name}",
- 'logo_url': logo_url,
- 'tech_stack': tech_stack,
- 'contributions': contributions,
- 'contribution_markdown': "
".join(item['markdown'] for item in contributions),
- })
-
- months.append({
- 'month_number': month_sort,
- 'month_name': month_name,
- 'rows': month_rows,
- })
-
- years.append({
- 'year': year,
- 'months': months,
- })
-
- return {
- 'title': 'OSS Contributions',
- 'featured_projects': featured_projects,
- 'years': years,
- 'status_legend': status_legend,
- }
-
-
-def generate_markdown(contributions_by_date, featured_repos, output_file="README.md"):
- model = build_readme_model(contributions_by_date, featured_repos)
-
- # Generate README
- with open(output_file, "w") as f:
- f.write("# OSS Contributions\n\n")
-
- # Featured Section
- if model['featured_projects']:
- f.write("## Featured Projects\n\n")
- f.write("
\n")
- for project in model['featured_projects']:
- repo = project['repo_name']
- f.write(f" \n")
- f.write(f" \n")
- f.write(" \n")
- f.write("
\n")
+ for project in model['featured_projects']:
+ repo = project['repo_name']
+ f.write(f" \n")
+ f.write(f" \n")
+ f.write(" \n")
+ f.write("