From 6dccbb6175c2026b0cd0fa6e83e1a7852e5113f6 Mon Sep 17 00:00:00 2001 From: Cris Nevares Date: Wed, 20 May 2026 11:53:14 -0500 Subject: [PATCH 1/5] Check for nearby chapters Signed-off-by: Cris Nevares Signed-off-by: Cris Nevares --- .github/workflows/check-nearby-chapters.yaml | 57 ++++ .../scripts/check_nearby_chapters.py | 265 ++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 .github/workflows/check-nearby-chapters.yaml create mode 100644 .github/workflows/scripts/check_nearby_chapters.py diff --git a/.github/workflows/check-nearby-chapters.yaml b/.github/workflows/check-nearby-chapters.yaml new file mode 100644 index 0000000..1dddc14 --- /dev/null +++ b/.github/workflows/check-nearby-chapters.yaml @@ -0,0 +1,57 @@ +name: Check for Nearby Chapters + +on: + issues: + types: [opened] + +jobs: + check-nearby-chapters: + if: contains(github.event.issue.title, '[Group Request]') + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests geopy beautifulsoup4 + + - name: Check for nearby chapters + id: check-chapters + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_TITLE: ${{ github.event.issue.title }} + run: | + python .github/workflows/scripts/check_nearby_chapters.py + + - name: Comment on issue + if: steps.check-chapters.outputs.nearby_chapters != '' + uses: actions/github-script@v7 + with: + script: | + const nearbyChapters = process.env.NEARBY_CHAPTERS; + const comment = `## ⚠️ Nearby Chapters Detected\n\n` + + `We found existing CNCF Community Group chapters near your requested location:\n\n` + + nearbyChapters + `\n\n` + + `Please review these existing chapters. If they are in your area, we recommend:\n` + + `- Reaching out to the current organizers to collaborate\n` + + `- Filing an [organizer request](https://github.com/cncf/communitygroups/issues/new?assignees=&labels=&projects=&template=organizer-request.yml&title=%5BOrganizer+Request%5D+Add+or+Remove+Organizer+Name) to join as a co-organizer\n\n` + + `If these chapters are not in your immediate area and you believe there is sufficient population to support a new chapter, please provide additional context in this issue.`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + env: + NEARBY_CHAPTERS: ${{ steps.check-chapters.outputs.nearby_chapters }} diff --git a/.github/workflows/scripts/check_nearby_chapters.py b/.github/workflows/scripts/check_nearby_chapters.py new file mode 100644 index 0000000..a8eaf7e --- /dev/null +++ b/.github/workflows/scripts/check_nearby_chapters.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +""" +Check for nearby CNCF Community Group chapters when a new chapter request is opened. +""" + +import json +import os +import re +import sys +import requests +from bs4 import BeautifulSoup +from geopy.geocoders import Nominatim +from geopy.distance import geodesic +from geopy.exc import GeocoderTimedOut, GeocoderServiceError + +# Distance threshold in kilometers +DISTANCE_THRESHOLD_KM = 100 + +def extract_location_from_issue(issue_body): + """ + Extract the city/location from the GitHub issue body. + The location is in the field labeled "City or location name for your CNCG" + """ + if not issue_body: + return None + + # Pattern to match the location field in the issue template + # Looking for "City or location name for your CNCG" section + pattern = r'###\s*City or location name for your CNCG\s*\n\s*(.+?)(?:\n\n|\n###|$)' + match = re.search(pattern, issue_body, re.IGNORECASE | re.DOTALL) + + if match: + location = match.group(1).strip() + # Remove common prefixes like "e.g." or "Cloud Native" + location = re.sub(r'^(e\.g\.\s*|Cloud Native\s*)', '', location, flags=re.IGNORECASE) + return location.strip() + + # Fallback: try to find any location-like text after the first heading + lines = issue_body.split('\n') + for i, line in enumerate(lines): + if 'City or location name' in line or 'location name for your CNCG' in line.lower(): + # Get the next non-empty line + for j in range(i + 1, len(lines)): + potential_location = lines[j].strip() + if potential_location and not potential_location.startswith('#'): + potential_location = re.sub(r'^(e\.g\.\s*|Cloud Native\s*)', '', potential_location, flags=re.IGNORECASE) + return potential_location.strip() + + return None + +def get_coordinates(location): + """ + Get latitude and longitude for a given location using geopy. + """ + try: + geolocator = Nominatim(user_agent="cncf-chapter-checker/1.0") + location_data = geolocator.geocode(location, timeout=10) + + if location_data: + return (location_data.latitude, location_data.longitude) + return None + except (GeocoderTimedOut, GeocoderServiceError) as e: + print(f"Geocoding error for '{location}': {e}", file=sys.stderr) + return None + +def fetch_existing_chapters(): + """ + Fetch the list of existing chapters from community.cncf.io/chapters/ + """ + url = "https://community.cncf.io/chapters/" + + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + + # Extract the localChapters JavaScript variable from the page + content = response.text + + # Find the start of the localChapters array + start_match = re.search(r'var\s+localChapters\s*=\s*\[', content) + + if not start_match: + print("Could not find localChapters variable in page", file=sys.stderr) + return get_fallback_chapters() + + # Find the matching closing bracket by counting brackets + start_pos = start_match.end() - 1 # Position of the opening '[' + bracket_count = 0 + end_pos = start_pos + + for i in range(start_pos, len(content)): + if content[i] == '[': + bracket_count += 1 + elif content[i] == ']': + bracket_count -= 1 + if bracket_count == 0: + end_pos = i + 1 + break + + if bracket_count != 0: + print("Could not find matching bracket for localChapters array", file=sys.stderr) + return get_fallback_chapters() + + chapters_json = content[start_pos:end_pos] + + try: + # Parse the JSON array + chapters_data = json.loads(chapters_json) + + chapters = [] + for chapter in chapters_data: + # Extract chapter information + city = chapter.get('city_name') or chapter.get('city', '') + country = chapter.get('country', '') + url = chapter.get('url', '') + latitude = chapter.get('latitude') + longitude = chapter.get('longitude') + + # Create a readable name + if city and country: + name = f"{city}, {country}" + elif city: + name = city + else: + # Extract name from URL as fallback + name = url.rstrip('/').split('/')[-1].replace('-', ' ').title() + + if name and url: + chapters.append({ + 'name': name, + 'url': url, + 'latitude': latitude, + 'longitude': longitude + }) + + print(f"Successfully parsed {len(chapters)} chapters from JavaScript data", file=sys.stderr) + return chapters + except json.JSONDecodeError as e: + print(f"Error parsing JSON data: {e}", file=sys.stderr) + return get_fallback_chapters() + + except requests.RequestException as e: + print(f"Error fetching chapters: {e}", file=sys.stderr) + return get_fallback_chapters() + except Exception as e: + print(f"Error parsing chapters data: {e}", file=sys.stderr) + return get_fallback_chapters() + +def get_fallback_chapters(): + """ + Fallback list of major CNCF chapters in case web scraping fails. + This should be updated periodically. + """ + return [ + {'name': 'San Francisco', 'url': 'https://community.cncf.io/cloud-native-san-francisco/'}, + {'name': 'New York City', 'url': 'https://community.cncf.io/cloud-native-new-york-city/'}, + {'name': 'London', 'url': 'https://community.cncf.io/cloud-native-london/'}, + {'name': 'Berlin', 'url': 'https://community.cncf.io/cloud-native-berlin/'}, + {'name': 'Amsterdam', 'url': 'https://community.cncf.io/cloud-native-amsterdam/'}, + {'name': 'Paris', 'url': 'https://community.cncf.io/cloud-native-paris/'}, + {'name': 'Tokyo', 'url': 'https://community.cncf.io/cloud-native-community-japan/'}, + {'name': 'Bangalore', 'url': 'https://community.cncf.io/cloud-native-bangalore/'}, + {'name': 'Sydney', 'url': 'https://community.cncf.io/cloud-native-sydney/'}, + {'name': 'Singapore', 'url': 'https://community.cncf.io/cloud-native-singapore/'}, + ] + +def find_nearby_chapters(requested_location, existing_chapters): + """ + Find chapters that are within DISTANCE_THRESHOLD_KM of the requested location. + """ + requested_coords = get_coordinates(requested_location) + + if not requested_coords: + print(f"Could not geocode requested location: {requested_location}", file=sys.stderr) + return [] + + print(f"Requested location coordinates: {requested_coords}", file=sys.stderr) + + nearby_chapters = [] + + for chapter in existing_chapters: + # Use coordinates from the chapter data if available, otherwise geocode + if chapter.get('latitude') is not None and chapter.get('longitude') is not None: + chapter_coords = (chapter['latitude'], chapter['longitude']) + else: + chapter_coords = get_coordinates(chapter['name']) + + if chapter_coords: + distance = geodesic(requested_coords, chapter_coords).kilometers + print(f"Distance to {chapter['name']}: {distance:.2f} km", file=sys.stderr) + + if distance < DISTANCE_THRESHOLD_KM: + nearby_chapters.append({ + 'name': chapter['name'], + 'url': chapter['url'], + 'distance_km': round(distance, 2) + }) + + # Sort by distance + nearby_chapters.sort(key=lambda x: x['distance_km']) + + return nearby_chapters + +def format_output(nearby_chapters): + """ + Format the nearby chapters as markdown for GitHub comment. + """ + if not nearby_chapters: + return "" + + output = [] + for chapter in nearby_chapters: + output.append(f"- **{chapter['name']}** (~{chapter['distance_km']} km away) - {chapter['url']}") + + return '\n'.join(output) + +def set_github_output(name, value): + """ + Set GitHub Actions output variable. + """ + github_output = os.getenv('GITHUB_OUTPUT') + if github_output: + with open(github_output, 'a') as f: + # Escape newlines and special characters for multiline output + value_escaped = value.replace('%', '%25').replace('\n', '%0A').replace('\r', '%0D') + f.write(f"{name}={value_escaped}\n") + else: + print(f"::set-output name={name}::{value}") + +def main(): + """ + Main function to check for nearby chapters. + """ + issue_body = os.getenv('ISSUE_BODY', '') + issue_title = os.getenv('ISSUE_TITLE', '') + + print(f"Issue title: {issue_title}", file=sys.stderr) + + # Extract location from issue + requested_location = extract_location_from_issue(issue_body) + + if not requested_location: + print("Could not extract location from issue body", file=sys.stderr) + set_github_output('nearby_chapters', '') + return + + print(f"Requested location: {requested_location}", file=sys.stderr) + + # Fetch existing chapters + existing_chapters = fetch_existing_chapters() + print(f"Found {len(existing_chapters)} existing chapters", file=sys.stderr) + + # Find nearby chapters + nearby_chapters = find_nearby_chapters(requested_location, existing_chapters) + + if nearby_chapters: + print(f"Found {len(nearby_chapters)} nearby chapters", file=sys.stderr) + output = format_output(nearby_chapters) + set_github_output('nearby_chapters', output) + else: + print("No nearby chapters found", file=sys.stderr) + set_github_output('nearby_chapters', '') + +if __name__ == '__main__': + main() From 7ca461d13c86feaeb972524c248c53e0c4cd54d6 Mon Sep 17 00:00:00 2001 From: Cris Nevares Date: Sat, 6 Jun 2026 19:42:25 -0500 Subject: [PATCH 2/5] Refactor for open-community-groups Signed-off-by: Cris Nevares --- .github/workflows/check-nearby-chapters.yaml | 2 +- .../scripts/check_nearby_chapters.py | 196 ++++++++++-------- 2 files changed, 110 insertions(+), 88 deletions(-) diff --git a/.github/workflows/check-nearby-chapters.yaml b/.github/workflows/check-nearby-chapters.yaml index 1dddc14..e173a04 100644 --- a/.github/workflows/check-nearby-chapters.yaml +++ b/.github/workflows/check-nearby-chapters.yaml @@ -23,7 +23,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install requests geopy beautifulsoup4 + pip install requests geopy - name: Check for nearby chapters id: check-chapters diff --git a/.github/workflows/scripts/check_nearby_chapters.py b/.github/workflows/scripts/check_nearby_chapters.py index a8eaf7e..33bc912 100644 --- a/.github/workflows/scripts/check_nearby_chapters.py +++ b/.github/workflows/scripts/check_nearby_chapters.py @@ -3,12 +3,10 @@ Check for nearby CNCF Community Group chapters when a new chapter request is opened. """ -import json import os import re import sys import requests -from bs4 import BeautifulSoup from geopy.geocoders import Nominatim from geopy.distance import geodesic from geopy.exc import GeocoderTimedOut, GeocoderServiceError @@ -16,6 +14,17 @@ # Distance threshold in kilometers DISTANCE_THRESHOLD_KM = 100 +# Open Community Groups JSON search API. The CNCF program migrated from +# community.cncf.io to ocgroups.dev; this endpoint returns groups (with +# coordinates) as JSON, replacing the old community.cncf.io HTML scrape. +# See https://github.com/cncf/open-community-groups (handler: /explore/groups/search). +CHAPTERS_API_URL = "https://ocgroups.dev/explore/groups/search" +CHAPTERS_COMMUNITY = "cncf" +# Max page size accepted by the API (MAX_PAGINATION_LIMIT in the server). +CHAPTERS_PAGE_SIZE = 100 +# Safety cap on pages fetched, in case `total` is unreliable. +CHAPTERS_MAX_PAGES = 50 + def extract_location_from_issue(issue_body): """ Extract the city/location from the GitHub issue body. @@ -63,105 +72,110 @@ def get_coordinates(location): print(f"Geocoding error for '{location}': {e}", file=sys.stderr) return None -def fetch_existing_chapters(): +def normalize_chapter(group): """ - Fetch the list of existing chapters from community.cncf.io/chapters/ + Map an Open Community Groups search result to the chapter shape used + downstream: {name, location, geocode_hint, url, latitude, longitude}. + Returns None if the group has no usable name or URL. """ - url = "https://community.cncf.io/chapters/" + name = group.get('name', '') + city = group.get('city') + country = group.get('country_name') + latitude = group.get('latitude') + longitude = group.get('longitude') + + # Human-readable location used as context in the posted comment. + if city and country: + location = f"{city}, {country}" + else: + location = city or country or '' - try: - response = requests.get(url, timeout=30) - response.raise_for_status() + # String to geocode only when the API does not supply coordinates. Prefer + # the physical location so it resolves cleanly; fall back to the group name. + geocode_hint = location or name - # Extract the localChapters JavaScript variable from the page - content = response.text + # Public group URL: /{community}/group/{slug}. The admin-managed + # "pretty" slug takes precedence when present (matches public_slug() server-side). + community = group.get('community_name') or CHAPTERS_COMMUNITY + slug = group.get('slug_pretty') or group.get('slug') + url = f"https://ocgroups.dev/{community}/group/{slug}" if slug else '' - # Find the start of the localChapters array - start_match = re.search(r'var\s+localChapters\s*=\s*\[', content) + if not (name and url): + return None - if not start_match: - print("Could not find localChapters variable in page", file=sys.stderr) - return get_fallback_chapters() + return { + 'name': name, + 'location': location, + 'geocode_hint': geocode_hint, + 'url': url, + 'latitude': latitude, + 'longitude': longitude, + } - # Find the matching closing bracket by counting brackets - start_pos = start_match.end() - 1 # Position of the opening '[' - bracket_count = 0 - end_pos = start_pos - - for i in range(start_pos, len(content)): - if content[i] == '[': - bracket_count += 1 - elif content[i] == ']': - bracket_count -= 1 - if bracket_count == 0: - end_pos = i + 1 - break - - if bracket_count != 0: - print("Could not find matching bracket for localChapters array", file=sys.stderr) - return get_fallback_chapters() - chapters_json = content[start_pos:end_pos] - - try: - # Parse the JSON array - chapters_data = json.loads(chapters_json) - - chapters = [] - for chapter in chapters_data: - # Extract chapter information - city = chapter.get('city_name') or chapter.get('city', '') - country = chapter.get('country', '') - url = chapter.get('url', '') - latitude = chapter.get('latitude') - longitude = chapter.get('longitude') - - # Create a readable name - if city and country: - name = f"{city}, {country}" - elif city: - name = city - else: - # Extract name from URL as fallback - name = url.rstrip('/').split('/')[-1].replace('-', ' ').title() - - if name and url: - chapters.append({ - 'name': name, - 'url': url, - 'latitude': latitude, - 'longitude': longitude - }) - - print(f"Successfully parsed {len(chapters)} chapters from JavaScript data", file=sys.stderr) - return chapters - except json.JSONDecodeError as e: - print(f"Error parsing JSON data: {e}", file=sys.stderr) +def fetch_existing_chapters(): + """ + Fetch the list of existing CNCF chapters from the Open Community Groups + JSON search API (ocgroups.dev), paging through all results. + """ + chapters = [] + + try: + for page in range(CHAPTERS_MAX_PAGES): + offset = page * CHAPTERS_PAGE_SIZE + # community is an array filter; serde_qs expects community[0]=... + params = [ + ('community[0]', CHAPTERS_COMMUNITY), + ('limit', CHAPTERS_PAGE_SIZE), + ('offset', offset), + ('sort_by', 'name'), + ] + response = requests.get(CHAPTERS_API_URL, params=params, timeout=30) + response.raise_for_status() + payload = response.json() + + groups = payload.get('groups', []) + for group in groups: + chapter = normalize_chapter(group) + if chapter: + chapters.append(chapter) + + total = payload.get('total', 0) + # Stop once we've fetched everything (or the page came back short). + if len(groups) < CHAPTERS_PAGE_SIZE or offset + CHAPTERS_PAGE_SIZE >= total: + break + + if not chapters: + print("No chapters returned by the API", file=sys.stderr) return get_fallback_chapters() + print(f"Successfully fetched {len(chapters)} chapters from the API", file=sys.stderr) + return chapters + except requests.RequestException as e: print(f"Error fetching chapters: {e}", file=sys.stderr) return get_fallback_chapters() - except Exception as e: + except (ValueError, KeyError) as e: print(f"Error parsing chapters data: {e}", file=sys.stderr) return get_fallback_chapters() def get_fallback_chapters(): """ - Fallback list of major CNCF chapters in case web scraping fails. + Fallback list of major CNCF chapters in case the API call fails. + Coordinates are embedded so this path does not depend on geocoding. This should be updated periodically. """ return [ - {'name': 'San Francisco', 'url': 'https://community.cncf.io/cloud-native-san-francisco/'}, - {'name': 'New York City', 'url': 'https://community.cncf.io/cloud-native-new-york-city/'}, - {'name': 'London', 'url': 'https://community.cncf.io/cloud-native-london/'}, - {'name': 'Berlin', 'url': 'https://community.cncf.io/cloud-native-berlin/'}, - {'name': 'Amsterdam', 'url': 'https://community.cncf.io/cloud-native-amsterdam/'}, - {'name': 'Paris', 'url': 'https://community.cncf.io/cloud-native-paris/'}, - {'name': 'Tokyo', 'url': 'https://community.cncf.io/cloud-native-community-japan/'}, - {'name': 'Bangalore', 'url': 'https://community.cncf.io/cloud-native-bangalore/'}, - {'name': 'Sydney', 'url': 'https://community.cncf.io/cloud-native-sydney/'}, - {'name': 'Singapore', 'url': 'https://community.cncf.io/cloud-native-singapore/'}, + {'name': 'San Francisco, USA', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 37.7749, 'longitude': -122.4194}, + {'name': 'New York City, USA', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 40.7128, 'longitude': -74.0060}, + {'name': 'London, UK', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 51.5074, 'longitude': -0.1278}, + {'name': 'Berlin, Germany', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 52.5200, 'longitude': 13.4050}, + {'name': 'Amsterdam, Netherlands', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 52.3676, 'longitude': 4.9041}, + {'name': 'Paris, France', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 48.8566, 'longitude': 2.3522}, + {'name': 'Tokyo, Japan', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 35.6762, 'longitude': 139.6503}, + {'name': 'Bangalore, India', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 12.9716, 'longitude': 77.5946}, + {'name': 'Sydney, Australia', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': -33.8688, 'longitude': 151.2093}, + {'name': 'Singapore', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 1.3521, 'longitude': 103.8198}, ] def find_nearby_chapters(requested_location, existing_chapters): @@ -183,7 +197,7 @@ def find_nearby_chapters(requested_location, existing_chapters): if chapter.get('latitude') is not None and chapter.get('longitude') is not None: chapter_coords = (chapter['latitude'], chapter['longitude']) else: - chapter_coords = get_coordinates(chapter['name']) + chapter_coords = get_coordinates(chapter.get('geocode_hint') or chapter['name']) if chapter_coords: distance = geodesic(requested_coords, chapter_coords).kilometers @@ -192,6 +206,7 @@ def find_nearby_chapters(requested_location, existing_chapters): if distance < DISTANCE_THRESHOLD_KM: nearby_chapters.append({ 'name': chapter['name'], + 'location': chapter.get('location', ''), 'url': chapter['url'], 'distance_km': round(distance, 2) }) @@ -210,22 +225,29 @@ def format_output(nearby_chapters): output = [] for chapter in nearby_chapters: - output.append(f"- **{chapter['name']}** (~{chapter['distance_km']} km away) - {chapter['url']}") + location = f" — {chapter['location']}" if chapter.get('location') else "" + output.append( + f"- **{chapter['name']}**{location} (~{chapter['distance_km']} km away) - {chapter['url']}" + ) return '\n'.join(output) def set_github_output(name, value): """ - Set GitHub Actions output variable. + Set a GitHub Actions output variable. + + Uses the heredoc form required by the $GITHUB_OUTPUT file for multi-line + values; the older `%0A` percent-encoding is NOT decoded from that file and + would surface literal `%0A` in the posted comment. """ github_output = os.getenv('GITHUB_OUTPUT') if github_output: + # A delimiter that cannot appear in the value (per Actions guidance). + delimiter = 'EOF_NEARBY_CHAPTERS' with open(github_output, 'a') as f: - # Escape newlines and special characters for multiline output - value_escaped = value.replace('%', '%25').replace('\n', '%0A').replace('\r', '%0D') - f.write(f"{name}={value_escaped}\n") + f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") else: - print(f"::set-output name={name}::{value}") + print(f"{name}={value}") def main(): """ From 6e337c9c2f01014b040e2a541656bdefab92843d Mon Sep 17 00:00:00 2001 From: Cris Nevares Date: Sat, 6 Jun 2026 20:00:03 -0500 Subject: [PATCH 3/5] Only indicate regional chapters Signed-off-by: Cris Nevares --- .../scripts/check_nearby_chapters.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/scripts/check_nearby_chapters.py b/.github/workflows/scripts/check_nearby_chapters.py index 33bc912..d27e113 100644 --- a/.github/workflows/scripts/check_nearby_chapters.py +++ b/.github/workflows/scripts/check_nearby_chapters.py @@ -24,6 +24,11 @@ CHAPTERS_PAGE_SIZE = 100 # Safety cap on pages fetched, in case `total` is unreliable. CHAPTERS_MAX_PAGES = 50 +# Only region-specific chapters are relevant to a "nearby chapters" check. +# Other categories (virtual, technical/topic-based, hosted-project communities) +# are not tied to a geographic location even when they list an HQ city, so they +# are excluded. Matched on the category slug, which is stable across renames. +REGION_SPECIFIC_CATEGORY_SLUGS = {"regional"} def extract_location_from_issue(issue_body): """ @@ -76,8 +81,16 @@ def normalize_chapter(group): """ Map an Open Community Groups search result to the chapter shape used downstream: {name, location, geocode_hint, url, latitude, longitude}. - Returns None if the group has no usable name or URL. + Returns None for non-region-specific chapters, or those with no usable + name or URL. """ + # Skip chapters whose category is not region-specific (virtual, topic-based, + # hosted-project communities). They are not tied to a location, and trying to + # geolocate them by name produces false "nearby" matches. + category_slug = (group.get('category') or {}).get('slug') + if category_slug not in REGION_SPECIFIC_CATEGORY_SLUGS: + return None + name = group.get('name', '') city = group.get('city') country = group.get('country_name') @@ -90,9 +103,11 @@ def normalize_chapter(group): else: location = city or country or '' - # String to geocode only when the API does not supply coordinates. Prefer - # the physical location so it resolves cleanly; fall back to the group name. - geocode_hint = location or name + # String to geocode only when the API does not supply coordinates. Use the + # physical location, never the group name (geocoding an arbitrary name + # yields spurious matches); if there is no location, the chapter is skipped + # in find_nearby_chapters rather than guessed. + geocode_hint = location # Public group URL: /{community}/group/{slug}. The admin-managed # "pretty" slug takes precedence when present (matches public_slug() server-side). @@ -193,11 +208,14 @@ def find_nearby_chapters(requested_location, existing_chapters): nearby_chapters = [] for chapter in existing_chapters: - # Use coordinates from the chapter data if available, otherwise geocode + # Use coordinates from the chapter data if available. Otherwise geocode + # the location hint (city/country) only; skip if there is no hint. if chapter.get('latitude') is not None and chapter.get('longitude') is not None: chapter_coords = (chapter['latitude'], chapter['longitude']) + elif chapter.get('geocode_hint'): + chapter_coords = get_coordinates(chapter['geocode_hint']) else: - chapter_coords = get_coordinates(chapter.get('geocode_hint') or chapter['name']) + chapter_coords = None if chapter_coords: distance = geodesic(requested_coords, chapter_coords).kilometers From 35b3e38a99a161d533bb928ad9b7b8e863cea68b Mon Sep 17 00:00:00 2001 From: Cris Nevares Date: Sat, 13 Jun 2026 23:48:29 -0500 Subject: [PATCH 4/5] Use ocgroups.dev headers to find nearby chapters Add retries Add /recheck comment functionality in case of ocgroups.dev call failures Signed-off-by: Cris Nevares --- .github/workflows/check-nearby-chapters.yaml | 66 +++-- .../scripts/check_nearby_chapters.py | 239 ++++++++---------- 2 files changed, 152 insertions(+), 153 deletions(-) diff --git a/.github/workflows/check-nearby-chapters.yaml b/.github/workflows/check-nearby-chapters.yaml index e173a04..a6db2ba 100644 --- a/.github/workflows/check-nearby-chapters.yaml +++ b/.github/workflows/check-nearby-chapters.yaml @@ -3,10 +3,22 @@ name: Check for Nearby Chapters on: issues: types: [opened] + issue_comment: + types: [created] jobs: check-nearby-chapters: - if: contains(github.event.issue.title, '[Group Request]') + # Run when a [Group Request] issue is opened, or when someone comments + # "/recheck" on one (a manual retry, e.g. after a transient API failure). + # Comments made with the default GITHUB_TOKEN do not re-trigger workflows, + # so the bot's own "/recheck" instructions cannot start a loop. + if: > + (github.event_name == 'issues' && + contains(github.event.issue.title, '[Group Request]')) || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request == null && + contains(github.event.issue.title, '[Group Request]') && + startsWith(github.event.comment.body, '/recheck')) runs-on: ubuntu-latest permissions: issues: write @@ -34,24 +46,42 @@ jobs: python .github/workflows/scripts/check_nearby_chapters.py - name: Comment on issue - if: steps.check-chapters.outputs.nearby_chapters != '' uses: actions/github-script@v7 + env: + STATUS: ${{ steps.check-chapters.outputs.status }} + NEARBY_CHAPTERS: ${{ steps.check-chapters.outputs.nearby_chapters }} + EVENT_NAME: ${{ github.event_name }} with: script: | + const status = process.env.STATUS; const nearbyChapters = process.env.NEARBY_CHAPTERS; - const comment = `## ⚠️ Nearby Chapters Detected\n\n` + - `We found existing CNCF Community Group chapters near your requested location:\n\n` + - nearbyChapters + `\n\n` + - `Please review these existing chapters. If they are in your area, we recommend:\n` + - `- Reaching out to the current organizers to collaborate\n` + - `- Filing an [organizer request](https://github.com/cncf/communitygroups/issues/new?assignees=&labels=&projects=&template=organizer-request.yml&title=%5BOrganizer+Request%5D+Add+or+Remove+Organizer+Name) to join as a co-organizer\n\n` + - `If these chapters are not in your immediate area and you believe there is sufficient population to support a new chapter, please provide additional context in this issue.`; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); - env: - NEARBY_CHAPTERS: ${{ steps.check-chapters.outputs.nearby_chapters }} + // A /recheck comment is an explicit request, so acknowledge it even + // when nothing is found; a freshly opened issue stays silent in that case. + const isRecheck = process.env.EVENT_NAME === 'issue_comment'; + + let body = null; + if (status === 'found') { + body = `## ⚠️ Nearby Chapters Detected\n\n` + + `We found existing CNCF Community Group chapters near your requested location:\n\n` + + nearbyChapters + `\n\n` + + `Please review these existing chapters. If they are in your area, we recommend:\n` + + `- Reaching out to the current organizers to collaborate\n` + + `- Filing an [organizer request](https://github.com/cncf/communitygroups/issues/new?assignees=&labels=&projects=&template=organizer-request.yml&title=%5BOrganizer+Request%5D+Add+or+Remove+Organizer+Name) to join as a co-organizer\n\n` + + `If these chapters are not in your immediate area and you believe there is sufficient population to support a new chapter, please provide additional context in this issue.`; + } else if (status === 'error') { + body = `## ⚠️ Couldn't check for nearby chapters\n\n` + + `We couldn't reach the CNCF chapter directory to check for nearby chapters. This is usually temporary.\n\n` + + `Comment \`/recheck\` on this issue to run the check again.`; + } else if (status === 'none' && isRecheck) { + body = `## ✅ No nearby chapters found\n\n` + + `We re-checked and didn't find any existing CNCF Community Group chapters near your requested location.`; + } + + if (body) { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }); + } diff --git a/.github/workflows/scripts/check_nearby_chapters.py b/.github/workflows/scripts/check_nearby_chapters.py index d27e113..1992fc8 100644 --- a/.github/workflows/scripts/check_nearby_chapters.py +++ b/.github/workflows/scripts/check_nearby_chapters.py @@ -6,28 +6,43 @@ import os import re import sys +import time import requests from geopy.geocoders import Nominatim from geopy.distance import geodesic from geopy.exc import GeocoderTimedOut, GeocoderServiceError -# Distance threshold in kilometers +# Distance threshold for a chapter to count as "nearby". DISTANCE_THRESHOLD_KM = 100 +DISTANCE_THRESHOLD_M = DISTANCE_THRESHOLD_KM * 1000 # Open Community Groups JSON search API. The CNCF program migrated from -# community.cncf.io to ocgroups.dev; this endpoint returns groups (with -# coordinates) as JSON, replacing the old community.cncf.io HTML scrape. +# community.cncf.io to ocgroups.dev. This endpoint can do the distance search +# server-side: given the viewer's coordinates (via CloudFront-Viewer-* headers) +# plus distance + sort_by=distance, it returns only groups within range, already +# sorted nearest-first. That is much lighter than pulling every group and +# measuring distances locally, and it grows well as the chapter list does. # See https://github.com/cncf/open-community-groups (handler: /explore/groups/search). CHAPTERS_API_URL = "https://ocgroups.dev/explore/groups/search" CHAPTERS_COMMUNITY = "cncf" -# Max page size accepted by the API (MAX_PAGINATION_LIMIT in the server). -CHAPTERS_PAGE_SIZE = 100 -# Safety cap on pages fetched, in case `total` is unreliable. -CHAPTERS_MAX_PAGES = 50 -# Only region-specific chapters are relevant to a "nearby chapters" check. -# Other categories (virtual, technical/topic-based, hosted-project communities) -# are not tied to a geographic location even when they list an HQ city, so they -# are excluded. Matched on the category slug, which is stable across renames. +# Upper bound on results requested. The nearby set is small in practice; this is +# just a safety cap (the server's MAX_PAGINATION_LIMIT is 100). +RESULTS_LIMIT = 100 +# API-failure handling. The check is advisory, so on a transient failure we retry +# a few times with a short linear backoff; if it still fails, main() reports a +# `status` of "error" and the workflow invites the submitter to comment /recheck. +RETRY_ATTEMPTS = 3 +RETRY_BACKOFF_SECONDS = 2 +# Header names the OCG server reads viewer coordinates from. (The maintainers +# noted these names may change eventually.) +VIEWER_LATITUDE_HEADER = "CloudFront-Viewer-Latitude" +VIEWER_LONGITUDE_HEADER = "CloudFront-Viewer-Longitude" +# Only region-specific chapters are relevant to a "nearby chapters" check. The +# distance search already drops the coordinate-less groups (virtual, most +# topic-based ones), which is what previously caused false matches; this is a +# defensive filter for any non-regional group that still carries coordinates +# (e.g. a hosted-project community listing an HQ city). Matched on the category +# slug, which is stable across display-name renames. REGION_SPECIFIC_CATEGORY_SLUGS = {"regional"} def extract_location_from_issue(issue_body): @@ -80,13 +95,11 @@ def get_coordinates(location): def normalize_chapter(group): """ Map an Open Community Groups search result to the chapter shape used - downstream: {name, location, geocode_hint, url, latitude, longitude}. - Returns None for non-region-specific chapters, or those with no usable - name or URL. + downstream: {name, location, url, latitude, longitude}. Returns None for + non-region-specific chapters, or those with no usable name or URL. """ # Skip chapters whose category is not region-specific (virtual, topic-based, - # hosted-project communities). They are not tied to a location, and trying to - # geolocate them by name produces false "nearby" matches. + # hosted-project communities). They are not tied to a location. category_slug = (group.get('category') or {}).get('slug') if category_slug not in REGION_SPECIFIC_CATEGORY_SLUGS: return None @@ -94,8 +107,6 @@ def normalize_chapter(group): name = group.get('name', '') city = group.get('city') country = group.get('country_name') - latitude = group.get('latitude') - longitude = group.get('longitude') # Human-readable location used as context in the posted comment. if city and country: @@ -103,12 +114,6 @@ def normalize_chapter(group): else: location = city or country or '' - # String to geocode only when the API does not supply coordinates. Use the - # physical location, never the group name (geocoding an arbitrary name - # yields spurious matches); if there is no location, the chapter is skipped - # in find_nearby_chapters rather than guessed. - geocode_hint = location - # Public group URL: /{community}/group/{slug}. The admin-managed # "pretty" slug takes precedence when present (matches public_slug() server-side). community = group.get('community_name') or CHAPTERS_COMMUNITY @@ -121,118 +126,62 @@ def normalize_chapter(group): return { 'name': name, 'location': location, - 'geocode_hint': geocode_hint, 'url': url, - 'latitude': latitude, - 'longitude': longitude, + 'latitude': group.get('latitude'), + 'longitude': group.get('longitude'), } -def fetch_existing_chapters(): +def fetch_nearby_chapters(latitude, longitude): """ - Fetch the list of existing CNCF chapters from the Open Community Groups - JSON search API (ocgroups.dev), paging through all results. + Ask the Open Community Groups search API for CNCF chapters within + DISTANCE_THRESHOLD_KM of the given coordinates, sorted nearest-first. The + server does the distance filtering; we pass the viewer location via headers. + Retries a few times on failure (the failures are usually transient). Returns + a list of normalized chapters, or None if every attempt fails. """ - chapters = [] + headers = { + VIEWER_LATITUDE_HEADER: str(latitude), + VIEWER_LONGITUDE_HEADER: str(longitude), + } + # community is an array filter; serde_qs expects community[0]=... + params = [ + ('community[0]', CHAPTERS_COMMUNITY), + ('distance', DISTANCE_THRESHOLD_M), + ('sort_by', 'distance'), + ('limit', RESULTS_LIMIT), + ] - try: - for page in range(CHAPTERS_MAX_PAGES): - offset = page * CHAPTERS_PAGE_SIZE - # community is an array filter; serde_qs expects community[0]=... - params = [ - ('community[0]', CHAPTERS_COMMUNITY), - ('limit', CHAPTERS_PAGE_SIZE), - ('offset', offset), - ('sort_by', 'name'), - ] - response = requests.get(CHAPTERS_API_URL, params=params, timeout=30) + for attempt in range(1, RETRY_ATTEMPTS + 1): + try: + response = requests.get(CHAPTERS_API_URL, params=params, headers=headers, timeout=30) response.raise_for_status() - payload = response.json() - - groups = payload.get('groups', []) - for group in groups: - chapter = normalize_chapter(group) - if chapter: - chapters.append(chapter) - - total = payload.get('total', 0) - # Stop once we've fetched everything (or the page came back short). - if len(groups) < CHAPTERS_PAGE_SIZE or offset + CHAPTERS_PAGE_SIZE >= total: - break - - if not chapters: - print("No chapters returned by the API", file=sys.stderr) - return get_fallback_chapters() - - print(f"Successfully fetched {len(chapters)} chapters from the API", file=sys.stderr) - return chapters - - except requests.RequestException as e: - print(f"Error fetching chapters: {e}", file=sys.stderr) - return get_fallback_chapters() - except (ValueError, KeyError) as e: - print(f"Error parsing chapters data: {e}", file=sys.stderr) - return get_fallback_chapters() - -def get_fallback_chapters(): - """ - Fallback list of major CNCF chapters in case the API call fails. - Coordinates are embedded so this path does not depend on geocoding. - This should be updated periodically. - """ - return [ - {'name': 'San Francisco, USA', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 37.7749, 'longitude': -122.4194}, - {'name': 'New York City, USA', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 40.7128, 'longitude': -74.0060}, - {'name': 'London, UK', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 51.5074, 'longitude': -0.1278}, - {'name': 'Berlin, Germany', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 52.5200, 'longitude': 13.4050}, - {'name': 'Amsterdam, Netherlands', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 52.3676, 'longitude': 4.9041}, - {'name': 'Paris, France', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 48.8566, 'longitude': 2.3522}, - {'name': 'Tokyo, Japan', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 35.6762, 'longitude': 139.6503}, - {'name': 'Bangalore, India', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 12.9716, 'longitude': 77.5946}, - {'name': 'Sydney, Australia', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': -33.8688, 'longitude': 151.2093}, - {'name': 'Singapore', 'url': 'https://ocgroups.dev/explore?community[0]=cncf&entity=groups', 'latitude': 1.3521, 'longitude': 103.8198}, - ] + groups = response.json().get('groups', []) + chapters = [c for c in (normalize_chapter(g) for g in groups) if c] + print(f"API returned {len(groups)} nearby groups ({len(chapters)} regional)", file=sys.stderr) + return chapters + except (requests.RequestException, ValueError) as e: + print(f"Attempt {attempt}/{RETRY_ATTEMPTS} to reach the chapter API failed: {e}", file=sys.stderr) + if attempt < RETRY_ATTEMPTS: + time.sleep(RETRY_BACKOFF_SECONDS * attempt) + + print(f"Giving up after {RETRY_ATTEMPTS} attempts to reach the chapter API", file=sys.stderr) + return None -def find_nearby_chapters(requested_location, existing_chapters): +def annotate_distance(requested_coords, chapters): """ - Find chapters that are within DISTANCE_THRESHOLD_KM of the requested location. + Add a 'distance_km' field (distance from requested_coords) to each chapter + that has coordinates, dropping any without. Used for display; the API has + already restricted results to within the threshold. """ - requested_coords = get_coordinates(requested_location) - - if not requested_coords: - print(f"Could not geocode requested location: {requested_location}", file=sys.stderr) - return [] - - print(f"Requested location coordinates: {requested_coords}", file=sys.stderr) - - nearby_chapters = [] - - for chapter in existing_chapters: - # Use coordinates from the chapter data if available. Otherwise geocode - # the location hint (city/country) only; skip if there is no hint. - if chapter.get('latitude') is not None and chapter.get('longitude') is not None: - chapter_coords = (chapter['latitude'], chapter['longitude']) - elif chapter.get('geocode_hint'): - chapter_coords = get_coordinates(chapter['geocode_hint']) - else: - chapter_coords = None - - if chapter_coords: - distance = geodesic(requested_coords, chapter_coords).kilometers - print(f"Distance to {chapter['name']}: {distance:.2f} km", file=sys.stderr) - - if distance < DISTANCE_THRESHOLD_KM: - nearby_chapters.append({ - 'name': chapter['name'], - 'location': chapter.get('location', ''), - 'url': chapter['url'], - 'distance_km': round(distance, 2) - }) - - # Sort by distance - nearby_chapters.sort(key=lambda x: x['distance_km']) - - return nearby_chapters + annotated = [] + for chapter in chapters: + lat, lon = chapter.get('latitude'), chapter.get('longitude') + if lat is None or lon is None: + continue + distance = geodesic(requested_coords, (lat, lon)).kilometers + annotated.append({**chapter, 'distance_km': round(distance, 2)}) + return annotated def format_output(nearby_chapters): """ @@ -267,6 +216,14 @@ def set_github_output(name, value): else: print(f"{name}={value}") +def report(status, nearby_chapters=''): + """ + Emit the two GitHub Actions outputs the workflow branches on: + `status` (found | none | error) and the `nearby_chapters` markdown list. + """ + set_github_output('status', status) + set_github_output('nearby_chapters', nearby_chapters) + def main(): """ Main function to check for nearby chapters. @@ -276,30 +233,42 @@ def main(): print(f"Issue title: {issue_title}", file=sys.stderr) - # Extract location from issue + # Extract location from issue. A body we can't parse isn't an API problem and + # a /recheck won't help, so report "none" (stay silent) rather than "error". requested_location = extract_location_from_issue(issue_body) if not requested_location: print("Could not extract location from issue body", file=sys.stderr) - set_github_output('nearby_chapters', '') + report('none') return print(f"Requested location: {requested_location}", file=sys.stderr) - # Fetch existing chapters - existing_chapters = fetch_existing_chapters() - print(f"Found {len(existing_chapters)} existing chapters", file=sys.stderr) + # Geocode the requested location so we can hand the coordinates to the API. + requested_coords = get_coordinates(requested_location) + if not requested_coords: + print(f"Could not geocode requested location: {requested_location}", file=sys.stderr) + report('none') + return + + print(f"Requested location coordinates: {requested_coords}", file=sys.stderr) + + # Let the API do the distance search. None means it failed after retries; + # report "error" so the workflow can invite a /recheck. + chapters = fetch_nearby_chapters(*requested_coords) + if chapters is None: + report('error') + return - # Find nearby chapters - nearby_chapters = find_nearby_chapters(requested_location, existing_chapters) + nearby_chapters = annotate_distance(requested_coords, chapters) + nearby_chapters.sort(key=lambda c: c['distance_km']) if nearby_chapters: print(f"Found {len(nearby_chapters)} nearby chapters", file=sys.stderr) - output = format_output(nearby_chapters) - set_github_output('nearby_chapters', output) + report('found', format_output(nearby_chapters)) else: print("No nearby chapters found", file=sys.stderr) - set_github_output('nearby_chapters', '') + report('none') if __name__ == '__main__': main() From 576b6f698b967012c77e861001f803d37107550f Mon Sep 17 00:00:00 2001 From: Cris Nevares Date: Tue, 16 Jun 2026 16:48:54 -0500 Subject: [PATCH 5/5] Add regional slug filtering on the initial query Signed-off-by: Cris Nevares --- .../scripts/check_nearby_chapters.py | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/scripts/check_nearby_chapters.py b/.github/workflows/scripts/check_nearby_chapters.py index 1992fc8..9fd1674 100644 --- a/.github/workflows/scripts/check_nearby_chapters.py +++ b/.github/workflows/scripts/check_nearby_chapters.py @@ -37,12 +37,11 @@ # noted these names may change eventually.) VIEWER_LATITUDE_HEADER = "CloudFront-Viewer-Latitude" VIEWER_LONGITUDE_HEADER = "CloudFront-Viewer-Longitude" -# Only region-specific chapters are relevant to a "nearby chapters" check. The -# distance search already drops the coordinate-less groups (virtual, most -# topic-based ones), which is what previously caused false matches; this is a -# defensive filter for any non-regional group that still carries coordinates -# (e.g. a hosted-project community listing an HQ city). Matched on the category -# slug, which is stable across display-name renames. +# Only region-specific chapters are relevant to a "nearby chapters" check. These +# slugs are sent to the API as group_category filters so the server returns only +# regional groups (see fetch_nearby_chapters); normalize_chapter re-checks them +# as a cheap safety net in case that filter ever stops being honored. Matched on +# the category slug, which is stable across display-name renames. REGION_SPECIFIC_CATEGORY_SLUGS = {"regional"} def extract_location_from_issue(issue_body): @@ -99,7 +98,8 @@ def normalize_chapter(group): non-region-specific chapters, or those with no usable name or URL. """ # Skip chapters whose category is not region-specific (virtual, topic-based, - # hosted-project communities). They are not tied to a location. + # hosted-project communities); they are not tied to a location. The API query + # already filters to these categories, so this is just a safety net. category_slug = (group.get('category') or {}).get('slug') if category_slug not in REGION_SPECIFIC_CATEGORY_SLUGS: return None @@ -144,13 +144,21 @@ def fetch_nearby_chapters(latitude, longitude): VIEWER_LATITUDE_HEADER: str(latitude), VIEWER_LONGITUDE_HEADER: str(longitude), } - # community is an array filter; serde_qs expects community[0]=... + # community and group_category are array filters; serde_qs expects an indexed + # form (community[0]=..., group_category[0]=...). Filtering by category here + # rather than client-side keeps the query light and, more importantly, avoids + # a page of results filling with non-regional groups (which carry coordinates) + # and crowding valid regional chapters out of the limit. params = [ ('community[0]', CHAPTERS_COMMUNITY), ('distance', DISTANCE_THRESHOLD_M), ('sort_by', 'distance'), ('limit', RESULTS_LIMIT), ] + params += [ + (f'group_category[{i}]', slug) + for i, slug in enumerate(sorted(REGION_SPECIFIC_CATEGORY_SLUGS)) + ] for attempt in range(1, RETRY_ATTEMPTS + 1): try: