From caf3c7ca9ce5a6ae594ab090ccd83181c2c7ffb9 Mon Sep 17 00:00:00 2001 From: HDCode Date: Mon, 20 Jul 2026 13:15:34 +0200 Subject: [PATCH] refactor: modularize source into src package with tests --- .github/workflows/test.yml | 29 ++ .github/workflows/update-readme.yml | 13 + __test__/helpers.py | 47 +++ __test__/test_config.py | 33 ++ __test__/test_fetch.py | 40 +++ __test__/test_gen.py | 67 ---- __test__/test_model.py | 74 ++++ __test__/test_sheet.py | 44 +++ generate_readme.py | 510 +--------------------------- src/__init__.py | 12 + src/cli.py | 58 ++++ src/config.py | 92 +++++ src/github.py | 73 ++++ src/model.py | 157 +++++++++ src/render.py | 100 ++++++ src/sheet.py | 77 +++++ 16 files changed, 856 insertions(+), 570 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 __test__/helpers.py create mode 100644 __test__/test_config.py create mode 100644 __test__/test_fetch.py delete mode 100644 __test__/test_gen.py create mode 100644 __test__/test_model.py create mode 100644 __test__/test_sheet.py create mode 100644 src/__init__.py create mode 100644 src/cli.py create mode 100644 src/config.py create mode 100644 src/github.py create mode 100644 src/model.py create mode 100644 src/render.py create mode 100644 src/sheet.py 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" \"{repo}\"\n") - f.write(" \n") - f.write("

\n\n") - - for year_data in model['years']: - year = year_data['year'] - f.write(f"# {year}\n\n") - - for month_data in year_data['months']: - month_name = month_data['month_name'] - f.write(f"## {month_name}\n\n") - - # Table Header - f.write("| Status | Project | Tech Stack | Contribution |\n") - f.write("| :---: | :--- | :---: | :--- |\n") - - for row in month_data['rows']: - repo_display = ( - f"" - f"" - "" - ) - f.write( - f"| {row['status_icon']} | {repo_display} | {row['tech_stack']} | {row['contribution_markdown']} |\n" - ) - - f.write("\n") - - f.write("## Status\n\n") - for item in model['status_legend']: - f.write(f"- {item['icon']} **{item['label']}**: {item['description']}\n") - f.write("\n") - - -def generate_json_snapshot(contributions_by_date, featured_repos, output_file="README_DATA.json"): - """ - Writes a machine-readable snapshot of the same data shown in README. - """ - model = build_readme_model(contributions_by_date, featured_repos) - json_model = { - 'title': model['title'], - 'featured_projects': model['featured_projects'], - 'years': [], - } - - for year_data in model['years']: - json_year = { - 'year': year_data['year'], - 'months': [], - } - - for month_data in year_data['months']: - json_month = { - 'month_number': month_data['month_number'], - 'month_name': month_data['month_name'], - 'rows': [], - } - - for row in month_data['rows']: - plain_contributions = [] - for item in row['contributions']: - plain_contributions.append({ - 'number': item['number'], - 'title': item['title'], - 'url': item['url'], - 'text': f"#{item['number']}: {item['title']}", - }) - - json_month['rows'].append({ - 'status': row['status'], - 'repo_name': row['repo_name'], - 'repo_url': row['repo_url'], - 'logo_url': row['logo_url'], - 'tech_stack': row['tech_stack'], - 'contributions': plain_contributions, - 'contribution_text': " | ".join(item['text'] for item in plain_contributions), - }) - - json_year['months'].append(json_month) - - json_model['years'].append(json_year) - - with open(output_file, "w") as f: - json.dump(json_model, f, indent=2, ensure_ascii=False) - f.write("\n") - - - -def fetch_urls_from_sheet(csv_url): - """ - Fetches URLs from a published Google Sheet CSV. - Assumes the sheet has a column that contains GitHub PR URLs. - """ - response = urllib.request.urlopen(csv_url) - csv_content = response.read().decode('utf-8') - reader = csv.DictReader(io.StringIO(csv_content)) - - url_data = [] # List of dicts {url: str, featured: bool, featured_order: float} - allowed = set() - - for i, row in enumerate(reader): - # 1. Parse Status Logic (Columns E and F usually) - if 'Status' in row and 'Value' in row: - status_key = row['Status'].strip().upper() - value_val = row['Value'].strip() - if status_key and value_val == '1': - allowed.add(status_key) - - # 2. Parse PR Data (Column B) - # Check if 'PR' column exists and has a valid content - if 'PR' in row: - value = (row['PR'] or "").strip() - if not value: - continue - - if "github.com" in value and "/pull/" in value: - is_featured = False - featured_order = float('inf') - - if 'Featured' in row and row['Featured'] and row['Featured'].upper() == 'YES': - is_featured = True - - if 'FeaturedOrder' in row and row['FeaturedOrder']: - try: - featured_order = float(row['FeaturedOrder']) - except ValueError: - pass # Keep default infinity - - url_data.append({'url': value, 'featured': is_featured, 'featured_order': featured_order, 'sheet_index': i}) - else: - print(f"Warning: Skipping invalid URL at row {i+2}: '{value}' (must contain 'github.com' and '/pull/')") - - return url_data, allowed - -def main(): - SHEET_URL = "https://docs.google.com/spreadsheets/d/e/2PACX-1vTfuJK1dGU8Exl0svlqdwVVn2tsdNjjs-bvDgMFJxFgfLkCbMzNWhM5QF7cIZvr6T2mt56pO9tagm3h/pub?gid=0&single=true&output=csv" - - if not SHEET_URL: - print("Error: SHEET_URL is not set. Please publish your Google Sheet as CSV and set the URL in the script.") - return - - print(f"Fetching URLs from Google Sheet...") - try: - url_data, allowed_statuses = fetch_urls_from_sheet(SHEET_URL) - except Exception as e: - print(f"Error fetching from Google Sheet: {e}") - return - - print(f"Found {len(url_data)} URLs.") - - # Treat empty set as no filtering (None) - if allowed_statuses is not None and len(allowed_statuses) == 0: - print("No status filters defined (empty set). Showing all PRs.") - allowed_statuses = None - elif allowed_statuses: - print(f"Filtering for statuses: {allowed_statuses}") - - data, featured_repos = fetch_urls(url_data, allowed_statuses) - generate_markdown(data, featured_repos) - generate_json_snapshot(data, featured_repos) - - print("Done! README.md and README_DATA.json updated.") +from src.cli import main if __name__ == "__main__": main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..3256ede --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,12 @@ +"""OSS contributions README generator. + +Submodules: + config – shared constants and lookup maps + github – ``gh`` CLI fetching of PR/repo data + sheet – Google Sheet CSV ingestion + model – data assembly (grouping + render model) + render – markdown and JSON renderers + cli – command-line entry point (``main``) +""" + +__all__ = ["config", "github", "sheet", "model", "render", "cli"] diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..fd5611d --- /dev/null +++ b/src/cli.py @@ -0,0 +1,58 @@ +"""Command-line entry point.""" + +import sys + +from .config import SHEET_URL +from .model import build_readme_model, fetch_urls +from .render import generate_json_snapshot, generate_markdown +from .sheet import fetch_urls_from_sheet + + +def _count_contributions(model): + return sum( + len(row['contributions']) + for year in model['years'] + for month in year['months'] + for row in month['rows'] + ) + + +def main(): + if not SHEET_URL: + print("Error: SHEET_URL is not set. Please publish your Google Sheet as CSV " + "and set the URL in the script.") + return 1 + + print("Fetching URLs from Google Sheet...") + try: + url_data, allowed_statuses = fetch_urls_from_sheet(SHEET_URL) + except Exception as e: + print(f"Error fetching from Google Sheet: {e}") + return 1 + + print(f"Found {len(url_data)} URLs.") + + # An empty set means "no filtering" (treat as None). + if allowed_statuses is not None and len(allowed_statuses) == 0: + print("No status filters defined (empty set). Showing all PRs.") + allowed_statuses = None + elif allowed_statuses: + print(f"Filtering for statuses: {allowed_statuses}") + + data, featured_repos = fetch_urls(url_data, allowed_statuses) + + model = build_readme_model(data, featured_repos) + + if _count_contributions(model) == 0: + print("No contributions found; refusing to overwrite existing artifacts.") + return 1 + + generate_markdown(data, featured_repos, model=model) + generate_json_snapshot(data, featured_repos, model=model) + + print("Done! README.md and README_DATA.json updated.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..76937b8 --- /dev/null +++ b/src/config.py @@ -0,0 +1,92 @@ +"""Shared constants and lookup maps for the OSS contributions generator.""" + +from urllib.parse import urlparse + +SHEET_URL = ( + "https://docs.google.com/spreadsheets/d/e/2PACX-1vTfuJK1dGU8Exl0svlqdwVVn2tsdNjjs-" + "bvDgMFJxFgfLkCbMzNWhM5QF7cIZvr6T2mt56pO9tagm3h/pub?gid=0&single=true&output=csv" +) + +GITHUB_HOST = "github.com" + +# Marker that a URL points at a pull request (path segment). +PR_PATH_MARKER = "/pull/" + +# Human-friendly topic aliases pulled from repo topics. +TOPIC_MAP = { + 'compose': 'Jetpack Compose', + 'react': 'React', + 'nextjs': 'Next.js', + 'typescript': 'TypeScript', + 'rust': 'Rust', +} + +# Custom logo overrides keyed by "owner/repo". +CUSTOM_LOGOS = { + 'ImranR98/Obtainium': ( + 'https://raw.githubusercontent.com/ImranR98/Obtainium/main/' + 'assets/graphics/icon_small.png' + ) +} + +# Emoji shown next to each PR status (single source for both table and legend). +STATUS_ICONS = { + 'DRAFT': '🚧', + 'OPEN': '🟢', + 'MERGED': '🟣', + 'CLOSED': '🔴', +} + +# Fallback icon when a status is missing from STATUS_ICONS. +DEFAULT_STATUS_ICON = '🔨' + +# Legend rendered at the bottom of the README, derived from STATUS_ICONS. +_STATUS_LEGEND_DESC = { + 'OPEN': ('Open', 'The pull request is currently open and active.'), + 'MERGED': ('Merged', 'The pull request has been merged into the codebase.'), + 'DRAFT': ('Draft', 'The pull request is a work in progress.'), + 'CLOSED': ('Closed', 'The pull request was closed without being merged.'), +} + +STATUS_LEGEND = [ + {'status': status, 'icon': icon, 'label': label, 'description': desc} + for status, icon in STATUS_ICONS.items() + for label, desc in [_STATUS_LEGEND_DESC.get(status, (status.title(), ''))] +] + +# Conventional-commit prefix -> emoji. +CONVENTIONAL_EMOJI = { + 'feat': '✨', + 'fix': '🐛', + 'refactor': '♻️', + 'docs': '📝', + 'style': '💄', + 'test': '✅', + 'chore': '🔧', + 'perf': '⚡', + 'ci': '👷', + 'build': '📦', + 'revert': '⏪', +} + +# Keyword fallback -> emoji (checked against the start of the title). +KEYWORD_EMOJI = { + 'add': '✨', + 'update': '🔄', + 'remove': '🗑️', + 'delete': '🗑️', + 'migrate': '🚚', + 'improve': '⚡', + 'enhance': '⚡', + 'optimize': '⚡', + 'bump': '⬆️', + 'upgrade': '⬆️', + 'deprecate': '🚨', +} + +DEFAULT_PR_EMOJI = '🔨' + + +def is_github_url(url): + """Return True if ``url`` points at github.com.""" + return urlparse(url).netloc == GITHUB_HOST diff --git a/src/github.py b/src/github.py new file mode 100644 index 0000000..26b8db1 --- /dev/null +++ b/src/github.py @@ -0,0 +1,73 @@ +"""GitHub data fetching via the ``gh`` CLI.""" + +import json +import subprocess +from urllib.parse import urlparse + +from .config import GITHUB_HOST, is_github_url + +repo_cache = {} + + +def get_pr_details(url): + """Fetch PR metadata (title, url, state, date, number, draft) via ``gh pr view``.""" + if not is_github_url(url): + return None + + try: + 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) + + parsed = urlparse(url) + 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 + + +def get_repo_details(repo_name): + """Fetch repo description and tech stack via ``gh repo view`` (cached).""" + 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) + + language = data.get('primaryLanguage', {}).get('name', '') if data.get('primaryLanguage') else '' + topics_data = data.get('repositoryTopics') + topics = [t['name'] for t in topics_data] if topics_data else [] + + tech_stack = [language] if language else [] + for t in topics: + from .config import TOPIC_MAP + name = TOPIC_MAP.get(t) + if name and 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': ''} diff --git a/src/model.py b/src/model.py new file mode 100644 index 0000000..6eb26fa --- /dev/null +++ b/src/model.py @@ -0,0 +1,157 @@ +"""Data assembly: grouping raw PRs into a render-ready model.""" + +import re +from collections import defaultdict +from datetime import datetime +from itertools import groupby + +from .config import ( + CONVENTIONAL_EMOJI, + CUSTOM_LOGOS, + DEFAULT_PR_EMOJI, + DEFAULT_STATUS_ICON, + KEYWORD_EMOJI, + STATUS_ICONS, + STATUS_LEGEND, +) +from .github import get_pr_details, get_repo_details + + +def get_pr_emoji(title): + """Detect an emoji for a PR based on its title.""" + title_lower = title.lower().strip() + + match = re.match(r'^(\w+)(?:\([^)]+\))?:', title_lower) + if match: + prefix = match.group(1) + if prefix in CONVENTIONAL_EMOJI: + return CONVENTIONAL_EMOJI[prefix] + + for keyword, emoji in KEYWORD_EMOJI.items(): + if title_lower.startswith(keyword): + return emoji + + return DEFAULT_PR_EMOJI + + +def fetch_urls(url_data, allowed_statuses=None): + """Fetch PR details and group them by year -> month.""" + contributions_by_date = defaultdict(lambda: defaultdict(list)) + featured_repos = {} + + 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) + + print(f"Processing {url}...") + details = get_pr_details(url) + if details: + status = 'DRAFT' if details.get('isDraft') else details['state'].upper() + + if allowed_statuses is not None and status not in allowed_statuses: + print(f"Skipping {url} (Status: {status} not in allowed list)") + continue + + details['status'] = status + details['sheet_index'] = sheet_index + + created_at = datetime.strptime(details['createdAt'], "%Y-%m-%dT%H:%M:%SZ") + year = created_at.year + month_name = created_at.strftime("%B") + month_sort = created_at.month + + if 'repository' in details: + repo_name = details['repository'].get('nameWithOwner') + if repo_name: + details['repo_info'] = get_repo_details(repo_name) + if is_featured: + 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 + + contributions_by_date[year][(month_sort, month_name)].append(details) + + return contributions_by_date, featured_repos + + +def build_readme_model(contributions_by_date, featured_repos): + """Build a deterministic model consumed by both renderers.""" + 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 = [] + for year in sorted(contributions_by_date.keys(), reverse=True): + 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)]) + prs.sort(key=lambda x: ( + x['repository']['nameWithOwner'].lower(), + x.get('status', 'OPEN').upper(), + x.get('sheet_index', 0), + )) + + month_rows = [] + grouped = groupby(prs, key=lambda x: ( + x['repository']['nameWithOwner'].lower(), x.get('status', 'OPEN').upper() + )) + + for (repo_name, group_status), repo_prs in grouped: + repo_prs_list = list(repo_prs) + first_pr = repo_prs_list[0] + + icon = STATUS_ICONS.get(group_status, DEFAULT_STATUS_ICON) + 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, + } diff --git a/src/render.py b/src/render.py new file mode 100644 index 0000000..b95e938 --- /dev/null +++ b/src/render.py @@ -0,0 +1,100 @@ +"""Renderers: markdown README and JSON snapshot.""" + +import json + +from .model import build_readme_model + + +def generate_markdown(contributions_by_date, featured_repos, output_file="README.md", model=None): + """Render the grouped contributions into a Markdown README.""" + if model is None: + model = build_readme_model(contributions_by_date, featured_repos) + + with open(output_file, "w") as f: + f.write("# OSS Contributions\n\n") + + 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" \"{repo}\"\n") + f.write(" \n") + f.write("

\n\n") + + for year_data in model['years']: + year = year_data['year'] + f.write(f"# {year}\n\n") + + for month_data in year_data['months']: + month_name = month_data['month_name'] + f.write(f"## {month_name}\n\n") + f.write("| Status | Project | Tech Stack | Contribution |\n") + f.write("| :---: | :--- | :---: | :--- |\n") + + for row in month_data['rows']: + repo_display = ( + f"" + f"" + "" + ) + f.write( + f"| {row['status_icon']} | {repo_display} | {row['tech_stack']} | {row['contribution_markdown']} |\n" + ) + + f.write("\n") + + f.write("## Status\n\n") + for item in model['status_legend']: + f.write(f"- {item['icon']} **{item['label']}**: {item['description']}\n") + f.write("\n") + + +def generate_json_snapshot(contributions_by_date, featured_repos, output_file="README_DATA.json", model=None): + """Write a machine-readable snapshot of the same data shown in README.""" + if model is None: + model = build_readme_model(contributions_by_date, featured_repos) + json_model = { + 'title': model['title'], + 'featured_projects': model['featured_projects'], + 'years': [], + } + + for year_data in model['years']: + json_year = {'year': year_data['year'], 'months': []} + + for month_data in year_data['months']: + json_month = { + 'month_number': month_data['month_number'], + 'month_name': month_data['month_name'], + 'rows': [], + } + + for row in month_data['rows']: + plain_contributions = [] + for item in row['contributions']: + plain_contributions.append({ + 'number': item['number'], + 'title': item['title'], + 'url': item['url'], + 'text': f"#{item['number']}: {item['title']}", + }) + + json_month['rows'].append({ + 'status': row['status'], + 'repo_name': row['repo_name'], + 'repo_url': row['repo_url'], + 'logo_url': row['logo_url'], + 'tech_stack': row['tech_stack'], + 'contributions': plain_contributions, + 'contribution_text': " | ".join(item['text'] for item in plain_contributions), + }) + + json_year['months'].append(json_month) + + json_model['years'].append(json_year) + + with open(output_file, "w") as f: + json.dump(json_model, f, indent=2, ensure_ascii=False) + f.write("\n") diff --git a/src/sheet.py b/src/sheet.py new file mode 100644 index 0000000..c7456e5 --- /dev/null +++ b/src/sheet.py @@ -0,0 +1,77 @@ +"""Google Sheet CSV ingestion (URL list + allowed statuses).""" + +import csv +import io +import urllib.request + +from .config import PR_PATH_MARKER, is_github_url + +INFINITY = float('inf') + + +def _read_csv(url): + """Open a CSV URL and return a file-like text handle.""" + with urllib.request.urlopen(url, timeout=30) as response: + return io.StringIO(response.read().decode('utf-8')) + + +def _parse_allowed(reader): + """Parse an allowed-status set from rows with ``Status``/``Value`` columns.""" + 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 + + +def _parse_sheet(reader): + """Parse ``(url_data, allowed_statuses)`` from sheet rows. + + ``url_data`` is a list of dicts: ``{url, featured, featured_order, sheet_index}``. + """ + url_data = [] + allowed = set() + + for i, row in enumerate(reader): + # 1. Parse status logic (columns "Status" / "Value"). + if 'Status' in row and 'Value' in row: + status_key = row['Status'].strip().upper() + value_val = row['Value'].strip() + if status_key and value_val == '1': + allowed.add(status_key) + + # 2. Parse PR data (column "PR"). + if 'PR' in row: + value = (row['PR'] or "").strip() + if not value: + continue + + if is_github_url(value) and PR_PATH_MARKER in value: + is_featured = bool( + row.get('Featured') and row['Featured'].upper() == 'YES' + ) + featured_order = INFINITY + if row.get('FeaturedOrder'): + try: + featured_order = float(row['FeaturedOrder']) + except ValueError: + pass + url_data.append({ + 'url': value, + 'featured': is_featured, + 'featured_order': featured_order, + 'sheet_index': i, + }) + else: + print( + f"Warning: Skipping invalid URL at row {i + 2}: '{value}' " + "(must contain 'github.com' and '/pull/')" + ) + + return url_data, allowed + + +def fetch_urls_from_sheet(csv_url): + """Fetch URLs from a published Google Sheet CSV via ``urlopen``.""" + return _parse_sheet(csv.DictReader(_read_csv(csv_url)))