diff --git a/.github/workflows/check-nearby-chapters.yaml b/.github/workflows/check-nearby-chapters.yaml new file mode 100644 index 0000000..a6db2ba --- /dev/null +++ b/.github/workflows/check-nearby-chapters.yaml @@ -0,0 +1,87 @@ +name: Check for Nearby Chapters + +on: + issues: + types: [opened] + issue_comment: + types: [created] + +jobs: + check-nearby-chapters: + # 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 + + 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 + + - 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 + 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; + // 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 new file mode 100644 index 0000000..9fd1674 --- /dev/null +++ b/.github/workflows/scripts/check_nearby_chapters.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +""" +Check for nearby CNCF Community Group chapters when a new chapter request is opened. +""" + +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 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 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" +# 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. 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): + """ + 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 normalize_chapter(group): + """ + Map an Open Community Groups search result to the chapter shape used + 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. 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 + + name = group.get('name', '') + city = group.get('city') + country = group.get('country_name') + + # Human-readable location used as context in the posted comment. + if city and country: + location = f"{city}, {country}" + else: + location = city or country or '' + + # 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 '' + + if not (name and url): + return None + + return { + 'name': name, + 'location': location, + 'url': url, + 'latitude': group.get('latitude'), + 'longitude': group.get('longitude'), + } + + +def fetch_nearby_chapters(latitude, longitude): + """ + 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. + """ + headers = { + VIEWER_LATITUDE_HEADER: str(latitude), + VIEWER_LONGITUDE_HEADER: str(longitude), + } + # 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: + response = requests.get(CHAPTERS_API_URL, params=params, headers=headers, timeout=30) + response.raise_for_status() + 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 annotate_distance(requested_coords, chapters): + """ + 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. + """ + 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): + """ + Format the nearby chapters as markdown for GitHub comment. + """ + if not nearby_chapters: + return "" + + output = [] + for chapter in nearby_chapters: + 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 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: + f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + 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. + """ + 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. 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) + report('none') + return + + print(f"Requested location: {requested_location}", 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 + + 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) + report('found', format_output(nearby_chapters)) + else: + print("No nearby chapters found", file=sys.stderr) + report('none') + +if __name__ == '__main__': + main()