Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions .github/workflows/update-readme.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]"
Expand Down
47 changes: 47 additions & 0 deletions __test__/helpers.py
Original file line number Diff line number Diff line change
@@ -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),
]
33 changes: 33 additions & 0 deletions __test__/test_config.py
Original file line number Diff line number Diff line change
@@ -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)
40 changes: 40 additions & 0 deletions __test__/test_fetch.py
Original file line number Diff line number Diff line change
@@ -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)
67 changes: 0 additions & 67 deletions __test__/test_gen.py

This file was deleted.

74 changes: 74 additions & 0 deletions __test__/test_model.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 44 additions & 0 deletions __test__/test_sheet.py
Original file line number Diff line number Diff line change
@@ -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)
Loading